diff --git a/Gax/composer.json b/Gax/composer.json index 4789cecff58f..07313bf2520b 100644 --- a/Gax/composer.json +++ b/Gax/composer.json @@ -25,7 +25,8 @@ "phpspec/prophecy-phpunit": "^2.1", "phpstan/phpstan": "^2.0", "google/cloud-tools": "^0.16.1", - "webmozart/assert": "^1.11.0" + "webmozart/assert": "^1.11.0", + "open-telemetry/sdk": "^1.13" }, "extra": { "component": { diff --git a/Gax/tests/Conformance/OtelTracingTest.php b/Gax/tests/Conformance/OtelTracingTest.php new file mode 100644 index 000000000000..531890c6effb --- /dev/null +++ b/Gax/tests/Conformance/OtelTracingTest.php @@ -0,0 +1,588 @@ +exporter = new InMemoryExporter(); + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor($this->exporter), + null, + ResourceInfoFactory::emptyResource() + ); + } + + protected function tearDown(): void + { + $this->tracerProvider->shutdown(); + parent::tearDown(); + } + + public function provideTransportType(): array + { + return [ + 'grpc' => ['grpc'], + 'rest' => ['rest'], + ]; + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingDisabledEmitsOnlyAppSpan(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::ECHO_REST_CONFIG, null); + $echoClient = new EchoClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + ]); + + $appSpan = $this->tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $response = $echoClient->echo(new EchoRequest(['content' => 'trace disabled'])); + $this->assertSame('trace disabled', $response->getContent()); + } finally { + $appScope->detach(); + $appSpan->end(); + $echoClient->close(); + } + + $spans = $this->exporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame('app-operation', $spans[0]->getName()); + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingSuccessfulEcho(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::ECHO_REST_CONFIG, $this->tracerProvider); + $echoClient = new EchoClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + 'openTelemetryTracerProvider' => $this->tracerProvider, + ]); + + $appSpan = $this->tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $response = $echoClient->echo(new EchoRequest(['content' => 'hello tracing'])); + $this->assertSame('hello tracing', $response->getContent()); + } finally { + $appScope->detach(); + $appSpan->end(); + $echoClient->close(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $this->exporter->getSpans(); + $expectedMethod = 'google.showcase.v1beta1.Echo/Echo'; + + $this->assertCount(3, $spans); + $t4Span = $spans[0]; + $t3Span = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($t3Span->getSpanId(), $t4Span->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $t4Span->getKind()); + $this->assertSame( + $transportType === 'grpc' ? $expectedMethod : 'POST /v1beta1/echo:echo', + $t4Span->getName() + ); + $this->assertSame(StatusCode::STATUS_OK, $t4Span->getStatus()->getCode()); + $this->assertSame('google-cloud-php', $t4Span->getInstrumentationScope()->getName()); + + $t4Attrs = $t4Span->getAttributes()->toArray(); + $this->assertSame($transportType === 'grpc' ? 'grpc' : 'http', $t4Attrs[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($expectedMethod, $t4Attrs[SpanAttributes::RPC_METHOD]); + $this->assertSame('OK', $t4Attrs[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame(self::SERVER_ADDRESS, $t4Attrs[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(self::SERVER_PORT, $t4Attrs[SpanAttributes::SERVER_PORT]); + if ($transportType === 'rest') { + $this->assertSame('POST', $t4Attrs[SpanAttributes::HTTP_REQUEST_METHOD]); + $this->assertSame(200, $t4Attrs[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + $this->assertSame('/v1beta1/echo:echo', $t4Attrs[SpanAttributes::URL_TEMPLATE]); + $this->assertStringEndsWith('/v1beta1/echo:echo', $t4Attrs[SpanAttributes::URL_FULL]); + } + $this->assertArrayNotHasKey(SpanAttributes::ERROR_TYPE, $t4Attrs); + $this->assertArrayNotHasKey(SpanAttributes::STATUS_MESSAGE, $t4Attrs); + $this->assertArrayNotHasKey(SpanAttributes::EXCEPTION_TYPE, $t4Attrs); + + $this->assertSame($exportedAppSpan->getSpanId(), $t3Span->getParentSpanId()); + $this->assertSame(SpanKind::KIND_INTERNAL, $t3Span->getKind()); + $this->assertSame($expectedMethod, $t3Span->getName()); + $this->assertSame(StatusCode::STATUS_OK, $t3Span->getStatus()->getCode()); + $this->assertSame('google-cloud-php', $t3Span->getInstrumentationScope()->getName()); + + $t3Attrs = $t3Span->getAttributes()->toArray(); + $this->assertSame($transportType === 'grpc' ? 'grpc' : 'http', $t3Attrs[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($expectedMethod, $t3Attrs[SpanAttributes::RPC_METHOD]); + $this->assertSame(self::SERVER_ADDRESS, $t3Attrs[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(self::SERVER_PORT, $t3Attrs[SpanAttributes::SERVER_PORT]); + $this->assertArrayNotHasKey(SpanAttributes::ERROR_TYPE, $t3Attrs); + $this->assertArrayNotHasKey(SpanAttributes::STATUS_MESSAGE, $t3Attrs); + $this->assertArrayNotHasKey(SpanAttributes::EXCEPTION_TYPE, $t3Attrs); + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingIdentityGetUserFailure(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::IDENTITY_REST_CONFIG, $this->tracerProvider); + $identityClient = new IdentityClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + 'openTelemetryTracerProvider' => $this->tracerProvider, + ]); + + try { + $request = new GetUserRequest([ + 'name' => IdentityClient::userName('nonexistent-user'), + ]); + try { + $identityClient->getUser($request); + $this->fail('Expected ApiException for nonexistent user'); + } catch (ApiException $e) { + $this->assertSame('NOT_FOUND', $e->getStatus()); + } + } finally { + $identityClient->close(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $this->exporter->getSpans(); + $expectedMethod = 'google.showcase.v1beta1.Identity/GetUser'; + + $this->assertCount(2, $spans); + $t4Span = $spans[0]; + $t3Span = $spans[1]; + + $this->assertSame($t3Span->getSpanId(), $t4Span->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $t4Span->getKind()); + $this->assertSame( + $transportType === 'grpc' ? $expectedMethod : 'GET /v1beta1/{name=users/*}', + $t4Span->getName() + ); + $this->assertSame(StatusCode::STATUS_ERROR, $t4Span->getStatus()->getCode()); + $this->assertSame('NOT_FOUND', $t4Span->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertSame('NOT_FOUND', $t4Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame(ApiException::class, $t4Span->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + if ($transportType === 'rest') { + $this->assertSame(404, $t4Span->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame('GET', $t4Span->getAttributes()->get(SpanAttributes::HTTP_REQUEST_METHOD)); + $this->assertSame('/v1beta1/{name=users/*}', $t4Span->getAttributes()->get(SpanAttributes::URL_TEMPLATE)); + } + + $this->assertSame(SpanKind::KIND_INTERNAL, $t3Span->getKind()); + $this->assertSame($expectedMethod, $t3Span->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $t3Span->getStatus()->getCode()); + $this->assertSame('NOT_FOUND', $t3Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame(ApiException::class, $t3Span->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + $this->assertNotEmpty($t3Span->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + $this->assertSame(self::SERVER_ADDRESS, $t3Span->getAttributes()->get(SpanAttributes::SERVER_ADDRESS)); + $this->assertSame(self::SERVER_PORT, $t3Span->getAttributes()->get(SpanAttributes::SERVER_PORT)); + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingRetrySucceedsWithSequenceService(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::SEQUENCE_REST_CONFIG, $this->tracerProvider); + $sequenceClient = new SequenceServiceClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + 'openTelemetryTracerProvider' => $this->tracerProvider, + ]); + + try { + $sequence = new Sequence([ + 'responses' => [ + new Response([ + 'status' => new Status([ + 'code' => Code::UNAVAILABLE, + 'message' => 'Temporary failure on attempt 1', + ]), + ]), + new Response([ + 'status' => new Status([ + 'code' => Code::OK, + ]), + ]), + ], + ]); + + $createdSequence = $sequenceClient->createSequence( + new CreateSequenceRequest(['sequence' => $sequence]) + ); + $this->exporter->getStorage()->exchangeArray([]); + + $appSpan = $this->tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $sequenceClient->attemptSequence( + new AttemptSequenceRequest(['name' => $createdSequence->getName()]) + ); + } finally { + $appScope->detach(); + $appSpan->end(); + } + } finally { + $sequenceClient->close(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $this->exporter->getSpans(); + $expectedMethod = 'google.showcase.v1beta1.SequenceService/AttemptSequence'; + $expectedT4Name = $transportType === 'grpc' + ? $expectedMethod + : 'POST /v1beta1/{name=sequences/*}'; + + $this->assertCount(4, $spans); + $t4Attempt1 = $spans[0]; + $t4Attempt2 = $spans[1]; + $t3Span = $spans[2]; + $exportedAppSpan = $spans[3]; + + $this->assertSame($exportedAppSpan->getSpanId(), $t3Span->getParentSpanId()); + $this->assertSame($t3Span->getSpanId(), $t4Attempt1->getParentSpanId()); + $this->assertSame($t3Span->getSpanId(), $t4Attempt2->getParentSpanId()); + + $this->assertSame(SpanKind::KIND_CLIENT, $t4Attempt1->getKind()); + $this->assertSame($expectedT4Name, $t4Attempt1->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $t4Attempt1->getStatus()->getCode()); + $this->assertSame( + 'UNAVAILABLE', + $t4Attempt1->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE) + ); + $this->assertSame('UNAVAILABLE', $t4Attempt1->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + if ($transportType === 'rest') { + $this->assertSame(503, $t4Attempt1->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertNull($t4Attempt1->getAttributes()->get(SpanAttributes::HTTP_REQUEST_RESEND_COUNT)); + } + + $this->assertSame(SpanKind::KIND_CLIENT, $t4Attempt2->getKind()); + $this->assertSame($expectedT4Name, $t4Attempt2->getName()); + $this->assertSame(StatusCode::STATUS_OK, $t4Attempt2->getStatus()->getCode()); + $this->assertSame('OK', $t4Attempt2->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertNull($t4Attempt2->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + if ($transportType === 'rest') { + $this->assertSame(200, $t4Attempt2->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame(1, $t4Attempt2->getAttributes()->get(SpanAttributes::HTTP_REQUEST_RESEND_COUNT)); + } + + $this->assertSame(SpanKind::KIND_INTERNAL, $t3Span->getKind()); + $this->assertSame($expectedMethod, $t3Span->getName()); + $this->assertSame(StatusCode::STATUS_OK, $t3Span->getStatus()->getCode()); + $this->assertNull($t3Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertNull($t3Span->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + $this->assertNull($t3Span->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingRetriesExhausted(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::ECHO_REST_CONFIG, $this->tracerProvider); + $echoClient = new EchoClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + 'openTelemetryTracerProvider' => $this->tracerProvider, + ]); + + $retrySettings = RetrySettings::constructDefault() + ->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [ApiStatus::UNAVAILABLE], + 'maxRetries' => 4, + 'initialRetryDelayMillis' => 10, + 'maxRetryDelayMillis' => 50, + ]); + + $appSpan = $this->tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $request = new EchoRequest([ + 'error' => new Status([ + 'code' => Code::UNAVAILABLE, + 'message' => 'Test error', + ]), + ]); + + try { + $echoClient->echo($request, ['retrySettings' => $retrySettings]); + $this->fail('Expected ApiException after exhausting retries'); + } catch (ApiException $e) { + $this->assertSame('UNAVAILABLE', $e->getStatus()); + } + } finally { + $appScope->detach(); + $appSpan->end(); + $echoClient->close(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $this->exporter->getSpans(); + $expectedMethod = 'google.showcase.v1beta1.Echo/Echo'; + $expectedT4Name = $transportType === 'grpc' + ? $expectedMethod + : 'POST /v1beta1/echo:echo'; + + // 5 T4 attempt spans (1 initial + 4 retries) + 1 T3 span + 1 APP span = 7 spans + $this->assertCount(7, $spans); + $t3Span = $spans[5]; + $exportedAppSpan = $spans[6]; + + $this->assertSame($exportedAppSpan->getSpanId(), $t3Span->getParentSpanId()); + for ($i = 0; $i < 5; $i++) { + $t4Span = $spans[$i]; + $this->assertSame($t3Span->getSpanId(), $t4Span->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $t4Span->getKind()); + $this->assertSame($expectedT4Name, $t4Span->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $t4Span->getStatus()->getCode()); + $this->assertSame( + 'UNAVAILABLE', + $t4Span->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE) + ); + $this->assertSame('UNAVAILABLE', $t4Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame('Test error', $t4Span->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + if ($transportType === 'rest') { + $this->assertSame(503, $t4Span->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame( + $i === 0 ? null : $i, + $t4Span->getAttributes()->get(SpanAttributes::HTTP_REQUEST_RESEND_COUNT) + ); + } + } + + $this->assertSame(SpanKind::KIND_INTERNAL, $t3Span->getKind()); + $this->assertSame($expectedMethod, $t3Span->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $t3Span->getStatus()->getCode()); + $this->assertSame('UNAVAILABLE', $t3Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame('Test error', $t3Span->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + $this->assertSame(ApiException::class, $t3Span->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + } + + /** + * @dataProvider provideTransportType + */ + public function testTracingErrorInfoReasonAndStatusCodes(string $transportType): void + { + $transport = $this->buildTransport($transportType, self::ECHO_REST_CONFIG, $this->tracerProvider); + $echoClient = new EchoClient([ + // @TODO: Remove apiEndpoint once https://github.com/googleapis/gapic-generator-php/pull/889 is merged + // and the Showcase client is regenerated. + 'apiEndpoint' => self::HOST, + 'credentials' => new InsecureCredentialsWrapper(), + 'transport' => $transport, + 'openTelemetryTracerProvider' => $this->tracerProvider, + ]); + + try { + // 1. Successful Echo (OK) + $echoClient->echo(new EchoRequest(['content' => 'success'])); + + // 2. Echo with ErrorInfo detail (verifies ErrorInfo reason is used for error.type) + $errorInfo = new ErrorInfo([ + 'reason' => 'CUSTOM_SHOWCASE_ERROR_REASON', + 'domain' => 'showcase.googleapis.com', + 'metadata' => ['key' => 'value'], + ]); + $anyDetail = new Any(); + $anyDetail->pack($errorInfo); + + try { + $echoClient->echo(new EchoRequest([ + 'error' => new Status([ + 'code' => Code::INVALID_ARGUMENT, + 'message' => 'Invalid argument with ErrorInfo', + 'details' => [$anyDetail], + ]), + ])); + $this->fail('Expected ApiException'); + } catch (ApiException $e) { + $this->assertSame('INVALID_ARGUMENT', $e->getStatus()); + } + + // 3. FailEchoWithDetails (standard Showcase error details RPC) + try { + $echoClient->failEchoWithDetails(new FailEchoWithDetailsRequest([ + 'message' => 'Failed echo with details', + ])); + $this->fail('Expected ApiException from failEchoWithDetails'); + } catch (ApiException $e) { + $this->assertNotEmpty($e->getErrorDetails()); + } + } finally { + $echoClient->close(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $this->exporter->getSpans(); + + $this->assertCount(6, $spans); + $okT4Span = $spans[0]; + $okT3Span = $spans[1]; + $errInfoT4Span = $spans[2]; + $errInfoT3Span = $spans[3]; + $failDetailsT4Span = $spans[4]; + $failDetailsT3Span = $spans[5]; + + $this->assertSame('OK', $okT4Span->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertNull($okT4Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertNull($okT3Span->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + + $this->assertSame( + 'INVALID_ARGUMENT', + $errInfoT4Span->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE) + ); + $this->assertSame( + 'CUSTOM_SHOWCASE_ERROR_REASON', + $errInfoT4Span->getAttributes()->get(SpanAttributes::ERROR_TYPE) + ); + $this->assertSame( + 'CUSTOM_SHOWCASE_ERROR_REASON', + $errInfoT3Span->getAttributes()->get(SpanAttributes::ERROR_TYPE) + ); + + $this->assertSame( + $transportType === 'grpc' + ? 'google.showcase.v1beta1.Echo/FailEchoWithDetails' + : 'POST /v1beta1/echo:failWithDetails', + $failDetailsT4Span->getName() + ); + $this->assertSame(StatusCode::STATUS_ERROR, $failDetailsT4Span->getStatus()->getCode()); + $this->assertSame(StatusCode::STATUS_ERROR, $failDetailsT3Span->getStatus()->getCode()); + $this->assertSame( + ApiException::class, + $failDetailsT3Span->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE) + ); + } + + private function buildTransport( + string $transportType, + string $restConfigPath, + ?TracerProviderInterface $tracerProvider + ): TransportInterface { + if (file_exists(self::PEM_PATH)) { + $pemContents = file_get_contents(self::PEM_PATH); + $grpcCredentials = ChannelCredentials::createSsl($pemContents); + $guzzleClient = new Client(['verify' => self::PEM_PATH]); + } else { + $grpcCredentials = ChannelCredentials::createInsecure(); + $guzzleClient = null; + } + + if ($transportType === 'grpc') { + return GrpcTransport::build( + self::HOST, + [ + 'stubOpts' => [ + 'credentials' => $grpcCredentials, + ], + 'openTelemetryTracerProvider' => $tracerProvider, + ] + ); + } + + $httpHandler = HttpHandlerFactory::build($guzzleClient); + return RestTransport::build( + self::HOST, + $restConfigPath, + [ + 'httpHandler' => [$httpHandler, 'async'], + 'hasEmulator' => !file_exists(self::PEM_PATH), + 'openTelemetryTracerProvider' => $tracerProvider, + ] + ); + } +} diff --git a/Gax/tests/Unit/Middleware/TracingMiddlewareTest.php b/Gax/tests/Unit/Middleware/TracingMiddlewareTest.php index 12d5a5572506..15a935bc7d70 100644 --- a/Gax/tests/Unit/Middleware/TracingMiddlewareTest.php +++ b/Gax/tests/Unit/Middleware/TracingMiddlewareTest.php @@ -33,13 +33,26 @@ namespace Google\ApiCore\Tests\Unit\Middleware; use Google\ApiCore\ApiException; +use Google\ApiCore\ApiStatus; use Google\ApiCore\Call; +use Google\ApiCore\Middleware\RetryMiddleware; use Google\ApiCore\Middleware\TracingMiddleware; +use Google\ApiCore\RetrySettings; use Google\ApiCore\Telemetry\SpanAttributes; +use Google\ApiCore\Testing\MockRequest; +use Google\ApiCore\Tests\Unit\TestTrait; +use Google\ApiCore\Transport\GrpcTransport; use Google\ApiCore\ValidationException; +use Google\Rpc\Code; +use Google\Rpc\ErrorInfo; +use Google\Rpc\Status; +use Grpc\ChannelCredentials; +use Grpc\UnaryCall; +use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\Promise; use GuzzleHttp\Promise\RejectedPromise; +use InvalidArgumentException; use OpenTelemetry\API\Trace\SpanBuilderInterface; use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\API\Trace\SpanKind; @@ -47,11 +60,21 @@ use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\ScopeInterface; +use OpenTelemetry\SDK\Resource\ResourceInfoFactory; +use OpenTelemetry\SDK\Trace\SpanDataInterface; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use ReflectionClass; +use RuntimeException; use stdClass; class TracingMiddlewareTest extends TestCase { + use ProphecyTrait; + use TestTrait; public function testTracingDisabledReturnsHandlerResult(): void { $call = new Call('test/method'); @@ -622,4 +645,556 @@ function () use ($innerPromise) { $this->assertTrue($cancelled); $this->assertSame('CANCELLED', $recordedAttributes[SpanAttributes::ERROR_TYPE]); } + + /** + * @dataProvider transportNameProvider + */ + public function testTracingDisabledDoesNotEmitSpanToExporter(string $transportName): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $response = new Status(['code' => Code::OK]); + $nextHandler = fn (Call $call, array $options) => Create::promiseFor($response); + + $middleware = new TracingMiddleware( + $nextHandler, + 'secretmanager.googleapis.com', + 443, + $transportName, + ['openTelemetryTracerProvider' => null] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call( + 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion', + Status::class, + new MockRequest() + ); + $result = $middleware($call, [])->wait(); + $this->assertSame($response, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + $spans = $exporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame('app-operation', $spans[0]->getName()); + } + + /** + * @dataProvider transportNameProvider + */ + public function testUnaryCallEmitsSpanAsChildOfActiveSpan(string $transportName): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $response = new Status(['code' => Code::OK]); + $nextHandler = fn (Call $call, array $options) => Create::promiseFor($response); + + $middleware = new TracingMiddleware( + $nextHandler, + 'secretmanager.googleapis.com', + 443, + $transportName, + [ + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.2.3', + ] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + $result = $middleware($call, [])->wait(); + $this->assertSame($response, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(2, $spans); + + $middlewareSpan = $spans[0]; + $exportedAppSpan = $spans[1]; + + $this->assertSame($exportedAppSpan->getSpanId(), $middlewareSpan->getParentSpanId()); + $this->assertSame($exportedAppSpan->getTraceId(), $middlewareSpan->getTraceId()); + $this->assertSame(SpanKind::KIND_INTERNAL, $middlewareSpan->getKind()); + $this->assertSame($method, $middlewareSpan->getName()); + $this->assertSame(StatusCode::STATUS_OK, $middlewareSpan->getStatus()->getCode()); + $this->assertSame('google-cloud-php', $middlewareSpan->getInstrumentationScope()->getName()); + $this->assertSame('1.2.3', $middlewareSpan->getInstrumentationScope()->getVersion()); + + $attrs = $middlewareSpan->getAttributes()->toArray(); + $this->assertSame($transportName, $attrs[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($method, $attrs[SpanAttributes::RPC_METHOD]); + $this->assertSame('secretmanager.googleapis.com', $attrs[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(443, $attrs[SpanAttributes::SERVER_PORT]); + $this->assertArrayNotHasKey(SpanAttributes::ERROR_TYPE, $attrs); + $this->assertArrayNotHasKey(SpanAttributes::STATUS_MESSAGE, $attrs); + $this->assertArrayNotHasKey(SpanAttributes::EXCEPTION_TYPE, $attrs); + } + + /** + * @dataProvider transportNameProvider + */ + public function testUnaryCallServerFailureAttributesWithInMemoryExporter(string $transportName): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + if ($transportName === 'http') { + $apiException1 = ApiException::createFromRestApiResponse( + 'Service unavailable', + Code::UNAVAILABLE + ); + $apiException2 = ApiException::createFromRestApiResponse( + 'API disabled in project', + Code::PERMISSION_DENIED, + [ + [ + '@type' => 'type.googleapis.com/google.rpc.ErrorInfo', + 'reason' => 'SERVICE_DISABLED', + 'domain' => 'googleapis.com', + 'metadata' => ['service' => 'secretmanager.googleapis.com'], + ], + ] + ); + } else { + $status1 = new stdClass(); + $status1->code = Code::UNAVAILABLE; + $status1->details = 'Service unavailable'; + $apiException1 = ApiException::createFromStdClass($status1); + + $errorInfo = new ErrorInfo([ + 'reason' => 'SERVICE_DISABLED', + 'domain' => 'googleapis.com', + ]); + $status2 = new stdClass(); + $status2->code = Code::PERMISSION_DENIED; + $status2->details = 'API disabled in project'; + $status2->metadata = [ + 'google.rpc.errorinfo-bin' => [$errorInfo->serializeToString()], + ]; + $apiException2 = ApiException::createFromStdClass($status2); + } + + $exceptions = [$apiException1, $apiException2]; + $nextHandler = function () use (&$exceptions) { + return Create::rejectionFor(array_shift($exceptions)); + }; + + $middleware = new TracingMiddleware( + $nextHandler, + 'secretmanager.googleapis.com', + 443, + $transportName, + ['openTelemetryTracerProvider' => $tracerProvider] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + + try { + $middleware($call, [])->wait(); + $this->fail('Expected ApiException 1'); + } catch (ApiException $e) { + $this->assertSame('UNAVAILABLE', $e->getStatus()); + } + + try { + $middleware($call, [])->wait(); + $this->fail('Expected ApiException 2'); + } catch (ApiException $e) { + $this->assertSame('SERVICE_DISABLED', $e->getReason()); + } + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $span1 = $spans[0]; + $span2 = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $span1->getParentSpanId()); + $this->assertSame(SpanKind::KIND_INTERNAL, $span1->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $span1->getStatus()->getCode()); + $attrs1 = $span1->getAttributes()->toArray(); + $this->assertSame($transportName, $attrs1[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame('UNAVAILABLE', $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Service unavailable', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + + $this->assertSame($exportedAppSpan->getSpanId(), $span2->getParentSpanId()); + $this->assertSame(SpanKind::KIND_INTERNAL, $span2->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $span2->getStatus()->getCode()); + $attrs2 = $span2->getAttributes()->toArray(); + $this->assertSame($transportName, $attrs2[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame('SERVICE_DISABLED', $attrs2[SpanAttributes::ERROR_TYPE]); + $this->assertSame('API disabled in project', $attrs2[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs2[SpanAttributes::EXCEPTION_TYPE]); + } + + /** + * @dataProvider transportNameProvider + */ + public function testUnaryCallClientFailureAndCancellationWithInMemoryExporter(string $transportName): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $callIndex = 0; + $nextHandler = function () use (&$callIndex) { + $callIndex++; + if ($callIndex === 1) { + throw new InvalidArgumentException('Client request validation failed'); + } + return new Promise(function () { + }, function () { + }); + }; + + $middleware = new TracingMiddleware( + $nextHandler, + 'secretmanager.googleapis.com', + 443, + $transportName, + ['openTelemetryTracerProvider' => $tracerProvider] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + + try { + $middleware($call, []); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Client request validation failed', $e->getMessage()); + } + + $promise = $middleware($call, []); + $promise->cancel(); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $exceptionSpan = $spans[0]; + $cancelSpan = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $exceptionSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_INTERNAL, $exceptionSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $exceptionSpan->getStatus()->getCode()); + $attrs1 = $exceptionSpan->getAttributes()->toArray(); + $this->assertSame($transportName, $attrs1[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame(InvalidArgumentException::class, $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame(InvalidArgumentException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + $this->assertSame('Client request validation failed', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertArrayNotHasKey(SpanAttributes::RPC_RESPONSE_STATUS_CODE, $attrs1); + + $this->assertSame($exportedAppSpan->getSpanId(), $cancelSpan->getParentSpanId()); + $this->assertSame(StatusCode::STATUS_ERROR, $cancelSpan->getStatus()->getCode()); + $attrs2 = $cancelSpan->getAttributes()->toArray(); + $this->assertSame('CANCELLED', $attrs2[SpanAttributes::ERROR_TYPE]); + } + + /** + * @return array + */ + public function transportNameProvider(): array + { + return [ + ['http'], + ['grpc'], + ]; + } + + public function testRetrySucceedsLinksAttemptSpansToParentMiddlewareSpan(): void + { + self::requiresGrpcExtension(); + + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $failStatus = new stdClass(); + $failStatus->code = Code::UNAVAILABLE; + $failStatus->details = 'Transient error on attempt 1'; + + $okResponse = new Status(['code' => Code::OK]); + $okStatus = new stdClass(); + $okStatus->code = Code::OK; + + $unaryCall1 = $this->prophesize(UnaryCall::class); + $unaryCall1->wait()->shouldBeCalledOnce()->willReturn([null, $failStatus]); + + $unaryCall2 = $this->prophesize(UnaryCall::class); + $unaryCall2->wait()->shouldBeCalledOnce()->willReturn([$okResponse, $okStatus]); + + $telemetryOptions = [ + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.2.3', + ]; + + $transport = $this->createGrpcTransport( + [$unaryCall1->reveal(), $unaryCall2->reveal()], + $telemetryOptions + ); + + $retrySettings = RetrySettings::constructDefault() + ->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [ApiStatus::UNAVAILABLE], + 'initialRetryDelayMillis' => 1, + 'maxRetryDelayMillis' => 5, + ]); + + $callStack = new RetryMiddleware([$transport, 'startUnaryCall'], $retrySettings); + $callStack = new TracingMiddleware( + $callStack, + 'secretmanager.googleapis.com', + 443, + 'grpc', + $telemetryOptions + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + $result = $callStack($call, [])->wait(); + $this->assertSame($okResponse, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(4, $spans); + + $attemptSpan1 = $spans[0]; + $attemptSpan2 = $spans[1]; + $middlewareSpan = $spans[2]; + $exportedAppSpan = $spans[3]; + + $this->assertSame($exportedAppSpan->getSpanId(), $middlewareSpan->getParentSpanId()); + $this->assertSame($middlewareSpan->getSpanId(), $attemptSpan1->getParentSpanId()); + $this->assertSame($middlewareSpan->getSpanId(), $attemptSpan2->getParentSpanId()); + + $this->assertSame(SpanKind::KIND_CLIENT, $attemptSpan1->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $attemptSpan1->getStatus()->getCode()); + $this->assertSame('UNAVAILABLE', $attemptSpan1->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertSame('UNAVAILABLE', $attemptSpan1->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame( + 'Transient error on attempt 1', + $attemptSpan1->getAttributes()->get(SpanAttributes::STATUS_MESSAGE) + ); + + $this->assertSame(SpanKind::KIND_CLIENT, $attemptSpan2->getKind()); + $this->assertSame(StatusCode::STATUS_OK, $attemptSpan2->getStatus()->getCode()); + $this->assertSame('OK', $attemptSpan2->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertNull($attemptSpan2->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertNull($attemptSpan2->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + + $this->assertSame(SpanKind::KIND_INTERNAL, $middlewareSpan->getKind()); + $this->assertSame(StatusCode::STATUS_OK, $middlewareSpan->getStatus()->getCode()); + $this->assertNull($middlewareSpan->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertNull($middlewareSpan->getAttributes()->get(SpanAttributes::STATUS_MESSAGE)); + $this->assertNull($middlewareSpan->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + } + + public function testRetriesExhaustedLinksAllAttemptSpansAndMarksParentSpanAsError(): void + { + self::requiresGrpcExtension(); + + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $failStatus = new stdClass(); + $failStatus->code = Code::UNAVAILABLE; + $failStatus->details = 'Persistent backend outage'; + + $unaryCalls = []; + for ($i = 0; $i < 3; $i++) { + $call = $this->prophesize(UnaryCall::class); + $call->wait()->shouldBeCalledOnce()->willReturn([null, $failStatus]); + $unaryCalls[] = $call->reveal(); + } + + $telemetryOptions = [ + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.2.3', + ]; + + $transport = $this->createGrpcTransport($unaryCalls, $telemetryOptions); + + $retrySettings = RetrySettings::constructDefault() + ->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [ApiStatus::UNAVAILABLE], + 'maxRetries' => 2, + 'initialRetryDelayMillis' => 1, + 'maxRetryDelayMillis' => 5, + ]); + + $callStack = new RetryMiddleware([$transport, 'startUnaryCall'], $retrySettings); + $callStack = new TracingMiddleware( + $callStack, + 'secretmanager.googleapis.com', + 443, + 'grpc', + $telemetryOptions + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + try { + $callStack($call, [])->wait(); + $this->fail('Expected ApiException after exhausting retries'); + } catch (ApiException $e) { + $this->assertSame('UNAVAILABLE', $e->getStatus()); + } + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(5, $spans); + + $attemptSpans = [$spans[0], $spans[1], $spans[2]]; + $middlewareSpan = $spans[3]; + $exportedAppSpan = $spans[4]; + + $this->assertSame($exportedAppSpan->getSpanId(), $middlewareSpan->getParentSpanId()); + + foreach ($attemptSpans as $attemptSpan) { + $this->assertSame($middlewareSpan->getSpanId(), $attemptSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $attemptSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $attemptSpan->getStatus()->getCode()); + $this->assertSame( + 'UNAVAILABLE', + $attemptSpan->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE) + ); + $this->assertSame('UNAVAILABLE', $attemptSpan->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame( + 'Persistent backend outage', + $attemptSpan->getAttributes()->get(SpanAttributes::STATUS_MESSAGE) + ); + } + + $this->assertSame(SpanKind::KIND_INTERNAL, $middlewareSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $middlewareSpan->getStatus()->getCode()); + $this->assertSame('UNAVAILABLE', $middlewareSpan->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + $this->assertSame( + 'Persistent backend outage', + $middlewareSpan->getAttributes()->get(SpanAttributes::STATUS_MESSAGE) + ); + $this->assertSame(ApiException::class, $middlewareSpan->getAttributes()->get(SpanAttributes::EXCEPTION_TYPE)); + } + + /** + * @param UnaryCall[] $unaryCalls + * @param array $telemetryOptions + */ + private function createGrpcTransport(array $unaryCalls, array $telemetryOptions = []): GrpcTransport + { + $transport = new class( + 'secretmanager.googleapis.com:443', + $unaryCalls + ) extends GrpcTransport { + /** @var UnaryCall[] */ + private array $unaryCalls; + + /** + * @param UnaryCall[] $unaryCalls + */ + public function __construct(string $hostname, array $unaryCalls) + { + $this->unaryCalls = $unaryCalls; + parent::__construct($hostname, ['credentials' => ChannelCredentials::createSsl()]); + } + + // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore + protected function _simpleRequest( + $method, + $arguments, + $deserialize, + array $metadata = [], + array $options = [] + ) { + return array_shift($this->unaryCalls); + } + }; + (new ReflectionClass(GrpcTransport::class)) + ->getMethod('setTelemetryOptions') + ->invoke($transport, $telemetryOptions, 'secretmanager.googleapis.com:443'); + return $transport; + } } diff --git a/Gax/tests/Unit/Transport/GrpcTransportTest.php b/Gax/tests/Unit/Transport/GrpcTransportTest.php index 5dcc57636e6e..f12b97a0740b 100644 --- a/Gax/tests/Unit/Transport/GrpcTransportTest.php +++ b/Gax/tests/Unit/Transport/GrpcTransportTest.php @@ -33,8 +33,11 @@ namespace Google\ApiCore\Tests\Unit\Transport; use Google\ApiCore\ApiException; +use Google\ApiCore\ApiStatus; use Google\ApiCore\Call; use Google\ApiCore\CredentialsWrapper; +use Google\ApiCore\Middleware\RetryMiddleware; +use Google\ApiCore\RetrySettings; use Google\ApiCore\Testing\MockGrpcTransport; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Telemetry\SpanAttributes; @@ -46,6 +49,7 @@ use Google\Protobuf\Internal\Message; use Google\Protobuf\RepeatedField; use Google\Rpc\Code; +use Google\Rpc\ErrorInfo; use Google\Rpc\Status; use Grpc\BaseStub; use Grpc\CallInvoker; @@ -61,11 +65,17 @@ use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; +use OpenTelemetry\SDK\Resource\ResourceInfoFactory; +use OpenTelemetry\SDK\Trace\SpanDataInterface; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\LoggerInterface; use ReflectionClass; +use RuntimeException; use stdClass; use TypeError; @@ -921,6 +931,373 @@ public function testBuildSetsTelemetryOptions(): void $this->assertSame(443, $portProp->getValue($transport)); } + public function testStartUnaryCallDoesNotEmitSpanWhenTracingDisabled(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $response = new Status(['code' => Code::OK]); + $status = new stdClass(); + $status->code = Code::OK; + + $unaryCall = $this->prophesize(UnaryCall::class); + $unaryCall->wait()->shouldBeCalledOnce()->willReturn([$response, $status]); + + $transport = $this->createTracedGrpcTransport([$unaryCall->reveal()]); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call( + 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion', + Status::class, + new MockRequest() + ); + $result = $transport->startUnaryCall($call, [])->wait(); + $this->assertSame($response, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + $spans = $exporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame('app-operation', $spans[0]->getName()); + } + + public function testStartUnaryCallRecordsParentSpanAndServerAttributes(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $response = new Status(['code' => Code::OK]); + $status = new stdClass(); + $status->code = Code::OK; + + $unaryCall = $this->prophesize(UnaryCall::class); + $unaryCall->wait()->shouldBeCalledOnce()->willReturn([$response, $status]); + + $transport = $this->createTracedGrpcTransport([$unaryCall->reveal()], [ + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.2.3', + ]); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + $result = $transport->startUnaryCall($call, [])->wait(); + $this->assertSame($response, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(2, $spans); + + $transportSpan = $spans[0]; + $exportedAppSpan = $spans[1]; + + $this->assertSame($exportedAppSpan->getSpanId(), $transportSpan->getParentSpanId()); + $this->assertSame($exportedAppSpan->getTraceId(), $transportSpan->getTraceId()); + $this->assertSame(SpanKind::KIND_CLIENT, $transportSpan->getKind()); + $this->assertSame($method, $transportSpan->getName()); + $this->assertSame(StatusCode::STATUS_OK, $transportSpan->getStatus()->getCode()); + $this->assertSame('google-cloud-php', $transportSpan->getInstrumentationScope()->getName()); + $this->assertSame('1.2.3', $transportSpan->getInstrumentationScope()->getVersion()); + + $attributes = $transportSpan->getAttributes()->toArray(); + $this->assertSame('grpc', $attributes[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($method, $attributes[SpanAttributes::RPC_METHOD]); + $this->assertSame('OK', $attributes[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('secretmanager.googleapis.com', $attributes[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(443, $attributes[SpanAttributes::SERVER_PORT]); + $this->assertArrayNotHasKey(SpanAttributes::STATUS_MESSAGE, $attributes); + $this->assertArrayNotHasKey(SpanAttributes::ERROR_TYPE, $attributes); + $this->assertArrayNotHasKey(SpanAttributes::EXCEPTION_TYPE, $attributes); + } + + public function testStartUnaryCallRecordsErrorInfoReasonAndServerFailureAttributes(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $statusWithoutErrorInfo = new stdClass(); + $statusWithoutErrorInfo->code = Code::UNAVAILABLE; + $statusWithoutErrorInfo->details = 'Service unavailable'; + + $errorInfo = new ErrorInfo([ + 'reason' => 'IAM_PERMISSION_DENIED', + 'domain' => 'googleapis.com', + ]); + $statusWithErrorInfo = new stdClass(); + $statusWithErrorInfo->code = Code::PERMISSION_DENIED; + $statusWithErrorInfo->details = 'Permission denied on resource'; + $statusWithErrorInfo->metadata = [ + 'google.rpc.errorinfo-bin' => [$errorInfo->serializeToString()], + ]; + + $unaryCall1 = $this->prophesize(UnaryCall::class); + $unaryCall1->wait()->shouldBeCalledOnce()->willReturn([null, $statusWithoutErrorInfo]); + + $unaryCall2 = $this->prophesize(UnaryCall::class); + $unaryCall2->wait()->shouldBeCalledOnce()->willReturn([null, $statusWithErrorInfo]); + + $transport = $this->createTracedGrpcTransport( + [$unaryCall1->reveal(), $unaryCall2->reveal()], + ['openTelemetryTracerProvider' => $tracerProvider] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected ApiException for call 1'); + } catch (ApiException $e) { + $this->assertSame('UNAVAILABLE', $e->getStatus()); + } + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected ApiException for call 2'); + } catch (ApiException $e) { + $this->assertSame('PERMISSION_DENIED', $e->getStatus()); + $this->assertSame('IAM_PERMISSION_DENIED', $e->getReason()); + } + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $span1 = $spans[0]; + $span2 = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $span1->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $span1->getKind()); + $this->assertSame($method, $span1->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $span1->getStatus()->getCode()); + $this->assertSame('Service unavailable', $span1->getStatus()->getDescription()); + $attrs1 = $span1->getAttributes()->toArray(); + $this->assertSame('grpc', $attrs1[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($method, $attrs1[SpanAttributes::RPC_METHOD]); + $this->assertSame('UNAVAILABLE', $attrs1[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('UNAVAILABLE', $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Service unavailable', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + $this->assertSame('secretmanager.googleapis.com', $attrs1[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(443, $attrs1[SpanAttributes::SERVER_PORT]); + + $this->assertSame($exportedAppSpan->getSpanId(), $span2->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $span2->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $span2->getStatus()->getCode()); + $this->assertSame('Permission denied on resource', $span2->getStatus()->getDescription()); + $attrs2 = $span2->getAttributes()->toArray(); + $this->assertSame('PERMISSION_DENIED', $attrs2[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('IAM_PERMISSION_DENIED', $attrs2[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Permission denied on resource', $attrs2[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs2[SpanAttributes::EXCEPTION_TYPE]); + } + + public function testStartUnaryCallRecordsClientFailureAndCancellationOnSpan(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $unaryCallForCancel = $this->prophesize(UnaryCall::class); + $unaryCallForCancel->cancel()->shouldBeCalledOnce(); + + $callCount = 0; + $transport = new class( + 'secretmanager.googleapis.com:443', + function () use (&$callCount, $unaryCallForCancel) { + $callCount++; + if ($callCount === 1) { + throw new RuntimeException('Client deadline exceeded before sending request'); + } + return $unaryCallForCancel->reveal(); + } + ) extends GrpcTransport { + /** @var callable */ + private $factory; + + public function __construct(string $hostname, callable $factory) + { + $this->factory = $factory; + parent::__construct($hostname, ['credentials' => ChannelCredentials::createSsl()]); + } + + // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore + protected function _simpleRequest( + $method, + $arguments, + $deserialize, + array $metadata = [], + array $options = [] + ) { + return ($this->factory)(); + } + }; + $this->setTelemetryOptions( + $transport, + ['openTelemetryTracerProvider' => $tracerProvider], + 'secretmanager.googleapis.com:443' + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + + try { + $transport->startUnaryCall($call, []); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('Client deadline exceeded before sending request', $e->getMessage()); + } + + $promise = $transport->startUnaryCall($call, []); + $promise->cancel(); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $exceptionSpan = $spans[0]; + $cancelledSpan = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $exceptionSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $exceptionSpan->getKind()); + $this->assertSame($method, $exceptionSpan->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $exceptionSpan->getStatus()->getCode()); + $attrs1 = $exceptionSpan->getAttributes()->toArray(); + $this->assertSame(RuntimeException::class, $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame(RuntimeException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + $this->assertSame('Client deadline exceeded before sending request', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertArrayNotHasKey(SpanAttributes::RPC_RESPONSE_STATUS_CODE, $attrs1); + + $this->assertSame($exportedAppSpan->getSpanId(), $cancelledSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $cancelledSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $cancelledSpan->getStatus()->getCode()); + $attrs2 = $cancelledSpan->getAttributes()->toArray(); + $this->assertSame('CANCELLED', $attrs2[SpanAttributes::ERROR_TYPE]); + $this->assertArrayNotHasKey(SpanAttributes::RPC_RESPONSE_STATUS_CODE, $attrs2); + } + + public function testStartUnaryCallWithRetryMiddlewareEmitsSpanPerAttempt(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + + $failStatus = new stdClass(); + $failStatus->code = Code::UNAVAILABLE; + $failStatus->details = 'Temporary backend error'; + + $okResponse = new Status(['code' => Code::OK]); + $okStatus = new stdClass(); + $okStatus->code = Code::OK; + + $unaryCall1 = $this->prophesize(UnaryCall::class); + $unaryCall1->wait()->shouldBeCalledOnce()->willReturn([null, $failStatus]); + + $unaryCall2 = $this->prophesize(UnaryCall::class); + $unaryCall2->wait()->shouldBeCalledOnce()->willReturn([$okResponse, $okStatus]); + + $transport = $this->createTracedGrpcTransport( + [$unaryCall1->reveal(), $unaryCall2->reveal()], + ['openTelemetryTracerProvider' => $tracerProvider] + ); + + $retrySettings = RetrySettings::constructDefault() + ->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [ApiStatus::UNAVAILABLE], + 'initialRetryDelayMillis' => 1, + 'maxRetryDelayMillis' => 5, + ]); + + $retryMiddleware = new RetryMiddleware([$transport, 'startUnaryCall'], $retrySettings); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $call = new Call($method, Status::class, new MockRequest()); + $result = $retryMiddleware($call, [])->wait(); + $this->assertSame($okResponse, $result); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $attempt1 = $spans[0]; + $attempt2 = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $attempt1->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $attempt1->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $attempt1->getStatus()->getCode()); + $this->assertSame('UNAVAILABLE', $attempt1->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertSame('UNAVAILABLE', $attempt1->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + + $this->assertSame($exportedAppSpan->getSpanId(), $attempt2->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $attempt2->getKind()); + $this->assertSame(StatusCode::STATUS_OK, $attempt2->getStatus()->getCode()); + $this->assertSame('OK', $attempt2->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertNull($attempt2->getAttributes()->get(SpanAttributes::ERROR_TYPE)); + } + public function testConstructAndSetTelemetryOptionsIgnoreInvalidHostname(): void { $transport = new GrpcTransport('dns:///localhost:7469', [ @@ -948,4 +1325,41 @@ private function setTelemetryOptions( ->getMethod('setTelemetryOptions') ->invoke($transport, $options, $apiEndpoint); } + + /** + * @param UnaryCall[] $unaryCalls + * @param array $telemetryOptions + */ + private function createTracedGrpcTransport(array $unaryCalls, array $telemetryOptions = []): GrpcTransport + { + $transport = new class( + 'secretmanager.googleapis.com:443', + $unaryCalls + ) extends GrpcTransport { + /** @var UnaryCall[] */ + private array $unaryCalls; + + /** + * @param UnaryCall[] $unaryCalls + */ + public function __construct(string $hostname, array $unaryCalls) + { + $this->unaryCalls = $unaryCalls; + parent::__construct($hostname, ['credentials' => ChannelCredentials::createSsl()]); + } + + // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore + protected function _simpleRequest( + $method, + $arguments, + $deserialize, + array $metadata = [], + array $options = [] + ) { + return array_shift($this->unaryCalls); + } + }; + $this->setTelemetryOptions($transport, $telemetryOptions, 'secretmanager.googleapis.com:443'); + return $transport; + } } diff --git a/Gax/tests/Unit/Transport/RestTransportTest.php b/Gax/tests/Unit/Transport/RestTransportTest.php index ca2231514dea..029cafdc7371 100644 --- a/Gax/tests/Unit/Transport/RestTransportTest.php +++ b/Gax/tests/Unit/Transport/RestTransportTest.php @@ -35,10 +35,13 @@ use BadMethodCallException; use Exception; use Google\ApiCore\ApiException; +use Google\ApiCore\ApiStatus; use Google\ApiCore\Call; use Google\ApiCore\CredentialsWrapper; +use Google\ApiCore\Middleware\RetryMiddleware; use Google\ApiCore\RequestBuilder; use Google\ApiCore\ResumableUpload\ResumableUploadTransportInterface; +use Google\ApiCore\RetrySettings; use Google\ApiCore\Telemetry\SpanAttributes; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Testing\MockRequestBody; @@ -53,6 +56,7 @@ use Google\Type\DateTime; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\Promise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use InvalidArgumentException; @@ -62,11 +66,17 @@ use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; +use OpenTelemetry\SDK\Resource\ResourceInfoFactory; +use OpenTelemetry\SDK\Trace\SpanDataInterface; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Argument; use Psr\Http\Message\RequestInterface; use ReflectionClass; +use RuntimeException; use TypeError; use UnexpectedValueException; @@ -855,14 +865,14 @@ public function testStartUnaryCallEndsSpanOnSynchronousException(): void $credentialsWrapper = $this->prophesize(CredentialsWrapper::class); $credentialsWrapper->getAuthorizationHeaderCallback(null) - ->willThrow(new \RuntimeException('Auth callback failed')); + ->willThrow(new RuntimeException('Auth callback failed')); $transport = $this->getTransport(); $this->setTelemetryOptions($transport, [ 'openTelemetryTracerProvider' => $tracerProvider, ]); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Auth callback failed'); $transport->startUnaryCall($this->call, [ @@ -894,6 +904,386 @@ public function testBuildSetsTelemetryOptions(): void $this->assertSame(443, $portProp->getValue($transport)); } + public function testStartUnaryCallDoesNotEmitSpanWhenTracerProviderNotConfigured(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $body = ['name' => 'hello', 'number' => 15]; + $httpHandler = fn (RequestInterface $request, array $options = []) => Create::promiseFor( + new Response(200, [], json_encode($body)) + ); + + $transport = $this->getTransport($httpHandler); + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $response = $transport->startUnaryCall($this->call, [])->wait(); + $this->assertSame('hello', $response->getName()); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + $spans = $exporter->getSpans(); + $this->assertCount(1, $spans); + $this->assertSame('app-operation', $spans[0]->getName()); + } + + public function testStartUnaryCallExportsHttpSuccessSpan(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $method = 'test.interface.v1.api/MethodWithBodyAndUrlPlaceholder'; + $body = ['name' => 'hello', 'number' => 15]; + $httpHandler = fn (RequestInterface $request, array $options = []) => Create::promiseFor( + new Response(200, [], json_encode($body)) + ); + + $transport = RestTransport::build( + 'secretmanager.googleapis.com:443', + __DIR__ . '/../testdata/resources/test_service_rest_client_config.php', + [ + 'httpHandler' => $httpHandler, + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.2.3', + ] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $message = (new MockRequestBody())->setName('message/foo'); + $call = new Call($method, MockResponse::class, $message); + $result = $transport->startUnaryCall($call, [])->wait(); + $this->assertSame('hello', $result->getName()); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(2, $spans); + + $httpSpan = $spans[0]; + $exportedAppSpan = $spans[1]; + + $this->assertSame($exportedAppSpan->getSpanId(), $httpSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $httpSpan->getKind()); + $this->assertSame('POST /v1/{name=message/**}', $httpSpan->getName()); + $this->assertSame(StatusCode::STATUS_OK, $httpSpan->getStatus()->getCode()); + $this->assertSame('google-cloud-php', $httpSpan->getInstrumentationScope()->getName()); + $this->assertSame('1.2.3', $httpSpan->getInstrumentationScope()->getVersion()); + + $attrs = $httpSpan->getAttributes()->toArray(); + $this->assertSame('http', $attrs[SpanAttributes::RPC_SYSTEM_NAME]); + $this->assertSame($method, $attrs[SpanAttributes::RPC_METHOD]); + $this->assertSame('OK', $attrs[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('POST', $attrs[SpanAttributes::HTTP_REQUEST_METHOD]); + $this->assertSame(200, $attrs[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + $this->assertSame('https://secretmanager.googleapis.com/v1/message/foo', $attrs[SpanAttributes::URL_FULL]); + $this->assertSame('/v1/{name=message/**}', $attrs[SpanAttributes::URL_TEMPLATE]); + $this->assertSame('secretmanager.googleapis.com', $attrs[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame(443, $attrs[SpanAttributes::SERVER_PORT]); + $this->assertArrayNotHasKey(SpanAttributes::HTTP_REQUEST_RESEND_COUNT, $attrs); + $this->assertArrayNotHasKey(SpanAttributes::ERROR_TYPE, $attrs); + } + + public function testStartUnaryCallExportsHttpErrorSpans(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $method = 'test.interface.v1.api/MethodWithUrlPlaceholder'; + $errorInfo = new ErrorInfo([ + 'reason' => 'IAM_PERMISSION_DENIED', + 'domain' => 'googleapis.com', + 'metadata' => ['key' => 'value'], + ]); + $anyDetail = new Any(); + $anyDetail->pack($errorInfo); + + $responses = [ + new Response( + 503, + [], + json_encode([ + 'error' => [ + 'code' => 503, + 'status' => 'UNAVAILABLE', + 'message' => 'Service unavailable', + ], + ]) + ), + new Response( + 403, + [], + json_encode([ + 'error' => [ + 'code' => 403, + 'status' => 'PERMISSION_DENIED', + 'message' => 'Permission denied on resource', + 'details' => [json_decode($anyDetail->serializeToJsonString(), true)], + ], + ]) + ), + ]; + + $httpHandler = function (RequestInterface $request, array $options = []) use (&$responses) { + $response = array_shift($responses); + return Create::rejectionFor(RequestException::create($request, $response)); + }; + + $transport = RestTransport::build( + 'secretmanager.googleapis.com:443', + __DIR__ . '/../testdata/resources/test_service_rest_client_config.php', + [ + 'httpHandler' => $httpHandler, + 'openTelemetryTracerProvider' => $tracerProvider, + ] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $message = (new MockRequestBody())->setName('message/foo'); + $call = new Call($method, MockResponse::class, $message); + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected ApiException for call 1'); + } catch (ApiException $e) { + $this->assertSame('UNAVAILABLE', $e->getStatus()); + } + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected ApiException for call 2'); + } catch (ApiException $e) { + $this->assertSame('PERMISSION_DENIED', $e->getStatus()); + $this->assertSame('IAM_PERMISSION_DENIED', $e->getReason()); + } + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $span1 = $spans[0]; + $span2 = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $span1->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $span1->getKind()); + $this->assertSame('GET /v1/{name=message/**}', $span1->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $span1->getStatus()->getCode()); + $attrs1 = $span1->getAttributes()->toArray(); + $this->assertSame(503, $attrs1[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + $this->assertSame('UNAVAILABLE', $attrs1[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('UNAVAILABLE', $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Service unavailable', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + + $this->assertSame($exportedAppSpan->getSpanId(), $span2->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $span2->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $span2->getStatus()->getCode()); + $attrs2 = $span2->getAttributes()->toArray(); + $this->assertSame(403, $attrs2[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + $this->assertSame('PERMISSION_DENIED', $attrs2[SpanAttributes::RPC_RESPONSE_STATUS_CODE]); + $this->assertSame('IAM_PERMISSION_DENIED', $attrs2[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Permission denied on resource', $attrs2[SpanAttributes::STATUS_MESSAGE]); + $this->assertSame(ApiException::class, $attrs2[SpanAttributes::EXCEPTION_TYPE]); + } + + public function testStartUnaryCallRecordsClientFailureAndCancellationOnSpan(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $method = 'test.interface.v1.api/MethodWithUrlPlaceholder'; + $callCount = 0; + $httpHandler = function (RequestInterface $request, array $options = []) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + return Create::rejectionFor(new RuntimeException('Connection timed out')); + } + return new Promise(); + }; + + $transport = RestTransport::build( + 'secretmanager.googleapis.com:443', + __DIR__ . '/../testdata/resources/test_service_rest_client_config.php', + [ + 'httpHandler' => $httpHandler, + 'openTelemetryTracerProvider' => $tracerProvider, + ] + ); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $message = (new MockRequestBody())->setName('message/foo'); + $call = new Call($method, MockResponse::class, $message); + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('Connection timed out', $e->getMessage()); + } + + $promise = $transport->startUnaryCall($call, []); + $promise->cancel(); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $exceptionSpan = $spans[0]; + $cancelledSpan = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $exceptionSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $exceptionSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $exceptionSpan->getStatus()->getCode()); + $attrs1 = $exceptionSpan->getAttributes()->toArray(); + $this->assertSame(RuntimeException::class, $attrs1[SpanAttributes::ERROR_TYPE]); + $this->assertSame(RuntimeException::class, $attrs1[SpanAttributes::EXCEPTION_TYPE]); + $this->assertSame('Connection timed out', $attrs1[SpanAttributes::STATUS_MESSAGE]); + $this->assertArrayNotHasKey(SpanAttributes::HTTP_RESPONSE_STATUS_CODE, $attrs1); + $this->assertArrayNotHasKey(SpanAttributes::RPC_RESPONSE_STATUS_CODE, $attrs1); + + $this->assertSame($exportedAppSpan->getSpanId(), $cancelledSpan->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $cancelledSpan->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $cancelledSpan->getStatus()->getCode()); + $attrs2 = $cancelledSpan->getAttributes()->toArray(); + $this->assertSame('CANCELLED', $attrs2[SpanAttributes::ERROR_TYPE]); + $this->assertArrayNotHasKey(SpanAttributes::HTTP_RESPONSE_STATUS_CODE, $attrs2); + } + + public function testStartUnaryCallWithRetryMiddlewareEmitsSpanPerAttemptWithResendCount(): void + { + $exporter = new InMemoryExporter(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + null, + ResourceInfoFactory::emptyResource() + ); + + $method = 'test.interface.v1.api/MethodWithBodyAndUrlPlaceholder'; + $callCount = 0; + $httpHandler = function (RequestInterface $request, array $options = []) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + return Create::rejectionFor( + RequestException::create( + $request, + new Response( + 503, + [], + json_encode([ + 'error' => [ + 'code' => 503, + 'status' => 'UNAVAILABLE', + 'message' => 'Temporary backend error', + ], + ]) + ) + ) + ); + } + return Create::promiseFor(new Response(200, [], json_encode(['name' => 'ok', 'number' => 1]))); + }; + + $transport = RestTransport::build( + 'secretmanager.googleapis.com:443', + __DIR__ . '/../testdata/resources/test_service_rest_client_config.php', + [ + 'httpHandler' => $httpHandler, + 'openTelemetryTracerProvider' => $tracerProvider, + ] + ); + + $retrySettings = RetrySettings::constructDefault() + ->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [ApiStatus::UNAVAILABLE], + 'initialRetryDelayMillis' => 1, + 'maxRetryDelayMillis' => 5, + ]); + + $retryMiddleware = new RetryMiddleware([$transport, 'startUnaryCall'], $retrySettings); + + $appSpan = $tracerProvider->getTracer('test-app')->spanBuilder('app-operation')->startSpan(); + $appScope = $appSpan->activate(); + + try { + $message = (new MockRequestBody())->setName('message/foo'); + $call = new Call($method, MockResponse::class, $message); + $result = $retryMiddleware($call, [])->wait(); + $this->assertSame('ok', $result->getName()); + } finally { + $appScope->detach(); + $appSpan->end(); + $tracerProvider->shutdown(); + } + + /** @var SpanDataInterface[] $spans */ + $spans = $exporter->getSpans(); + $this->assertCount(3, $spans); + + $attempt1 = $spans[0]; + $attempt2 = $spans[1]; + $exportedAppSpan = $spans[2]; + + $this->assertSame($exportedAppSpan->getSpanId(), $attempt1->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $attempt1->getKind()); + $this->assertSame(StatusCode::STATUS_ERROR, $attempt1->getStatus()->getCode()); + $this->assertSame(503, $attempt1->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame('UNAVAILABLE', $attempt1->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertNull($attempt1->getAttributes()->get(SpanAttributes::HTTP_REQUEST_RESEND_COUNT)); + + $this->assertSame($exportedAppSpan->getSpanId(), $attempt2->getParentSpanId()); + $this->assertSame(SpanKind::KIND_CLIENT, $attempt2->getKind()); + $this->assertSame(StatusCode::STATUS_OK, $attempt2->getStatus()->getCode()); + $this->assertSame(200, $attempt2->getAttributes()->get(SpanAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame('OK', $attempt2->getAttributes()->get(SpanAttributes::RPC_RESPONSE_STATUS_CODE)); + $this->assertSame(1, $attempt2->getAttributes()->get(SpanAttributes::HTTP_REQUEST_RESEND_COUNT)); + } + private function setTelemetryOptions(RestTransport $transport, array $options): void { (new ReflectionClass(RestTransport::class))