From 40f019c0e8dfa6b3d1903110b8c3442718e92508 Mon Sep 17 00:00:00 2001 From: Charlotte Yun Date: Thu, 10 Sep 2026 12:14:40 -0700 Subject: [PATCH] feat(Gax): implement Observability Phase 1 (Config, GAX Network, Auth Spans) --- Gax/VERSION | 2 +- Gax/composer.json | 3 +- Gax/src/AgentHeader.php | 51 +++ Gax/src/ClientOptionsTrait.php | 28 +- Gax/src/CredentialsWrapper.php | 58 ++- Gax/src/GapicClientTrait.php | 47 ++- Gax/src/Middleware/RetryMiddleware.php | 41 ++- Gax/src/Options/ClientOptions.php | 58 +++ Gax/src/Telemetry/AuthHttpHandler.php | 153 ++++++++ Gax/src/Telemetry/SpanAttributes.php | 70 ++++ Gax/src/Telemetry/TelemetryConfiguration.php | 96 +++++ Gax/src/Telemetry/TelemetryTrait.php | 203 +++++++++++ Gax/src/Transport/GrpcFallbackTransport.php | 52 ++- Gax/src/Transport/RestTransport.php | 86 +++-- Gax/tests/Unit/AgentHeaderTest.php | 12 + Gax/tests/Unit/ClientOptionsTraitTest.php | 48 ++- Gax/tests/Unit/CredentialsWrapperTest.php | 74 ++++ Gax/tests/Unit/GapicClientTraitTest.php | 48 +++ .../Unit/Middleware/RetryMiddlewareTest.php | 107 +++++- Gax/tests/Unit/Options/ClientOptionsTest.php | 53 +++ .../Unit/Telemetry/AuthHttpHandlerTest.php | 345 ++++++++++++++++++ .../Telemetry/TelemetryConfigurationTest.php | 169 +++++++++ .../Transport/GrpcFallbackTransportTest.php | 188 ++++++++++ .../Unit/Transport/RestTransportTest.php | 186 +++++++++- composer.json | 2 +- 25 files changed, 2128 insertions(+), 52 deletions(-) create mode 100644 Gax/src/Telemetry/AuthHttpHandler.php create mode 100644 Gax/src/Telemetry/SpanAttributes.php create mode 100644 Gax/src/Telemetry/TelemetryConfiguration.php create mode 100644 Gax/src/Telemetry/TelemetryTrait.php create mode 100644 Gax/tests/Unit/Options/ClientOptionsTest.php create mode 100644 Gax/tests/Unit/Telemetry/AuthHttpHandlerTest.php create mode 100644 Gax/tests/Unit/Telemetry/TelemetryConfigurationTest.php diff --git a/Gax/VERSION b/Gax/VERSION index 7f3a46a841e5..5a5c7211dc68 100644 --- a/Gax/VERSION +++ b/Gax/VERSION @@ -1 +1 @@ -1.49.0 +1.50.0 diff --git a/Gax/composer.json b/Gax/composer.json index 9e90d747eaeb..878b742f72b4 100644 --- a/Gax/composer.json +++ b/Gax/composer.json @@ -17,7 +17,8 @@ "guzzlehttp/psr7": "^2.6.3||^3.0", "google/common-protos": "^4.9", "google/longrunning": "~0.4", - "ramsey/uuid": "^4.0" + "ramsey/uuid": "^4.0", + "open-telemetry/api": "^1.8" }, "require-dev": { "phpunit/phpunit": "^9.6", diff --git a/Gax/src/AgentHeader.php b/Gax/src/AgentHeader.php index 4fda2b0e9a98..129cb51cb313 100644 --- a/Gax/src/AgentHeader.php +++ b/Gax/src/AgentHeader.php @@ -32,6 +32,9 @@ namespace Google\ApiCore; +use ReflectionClass; +use ReflectionException; + /** * Class containing functions used to build the Agent header. */ @@ -129,4 +132,52 @@ public static function readGapicVersionFromFile(string $callingClass) return Version::readVersionFile($versionFile); } + + /** + * Reads the package name from composer.json. In order to determine the file + * location, this method follows this procedure: + * - accepts a class name $callingClass + * - identifies the file defining that class + * - searches up the directory structure for the 'src' directory + * - looks in the directory above 'src' for a file named composer.json + * - parses the file to retrieve the "name" property + * + * @param string $callingClass + * @return string|null The package name or null if not found + */ + public static function readPackageNameFromFile(string $callingClass): ?string + { + static $packageNames = []; + + if (array_key_exists($callingClass, $packageNames)) { + return $packageNames[$callingClass]; + } + + try { + $callingClassFile = (new ReflectionClass($callingClass))->getFileName(); + if ($callingClassFile === false) { + return $packageNames[$callingClass] = null; + } + $srcPos = strrpos($callingClassFile, DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR); + if ($srcPos === false) { + return $packageNames[$callingClass] = null; + } + $composerFile = substr($callingClassFile, 0, $srcPos) . DIRECTORY_SEPARATOR . 'composer.json'; + if (!file_exists($composerFile)) { + return $packageNames[$callingClass] = null; + } + $content = file_get_contents($composerFile); + if ($content === false) { + return $packageNames[$callingClass] = null; + } + $json = json_decode($content, true); + if (isset($json['name']) && is_string($json['name'])) { + return $packageNames[$callingClass] = $json['name']; + } + } catch (ReflectionException $e) { + return $packageNames[$callingClass] = null; + } + + return $packageNames[$callingClass] = null; + } } diff --git a/Gax/src/ClientOptionsTrait.php b/Gax/src/ClientOptionsTrait.php index f5fbafdd4d23..3ebba17956ce 100644 --- a/Gax/src/ClientOptionsTrait.php +++ b/Gax/src/ClientOptionsTrait.php @@ -33,6 +33,7 @@ namespace Google\ApiCore; use Google\ApiCore\Options\ClientOptions; +use Google\ApiCore\Telemetry\TelemetryConfiguration; use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; @@ -122,6 +123,8 @@ private function buildClientOptions(array|ClientOptions $options) 'clientCertSource' => null, 'universeDomain' => null, 'logger' => null, + 'clientPackageName' => null, + 'openTelemetryTracerProvider' => null, ]; $supportedTransports = $this->supportedTransports(); @@ -147,6 +150,13 @@ private function buildClientOptions(array|ClientOptions $options) // we will not encounter missing keys $options += $defaultOptions; + if (empty($options['clientPackageName'])) { + $options['clientPackageName'] = AgentHeader::readPackageNameFromFile(static::class); + } + $options['openTelemetryTracerProvider'] = TelemetryConfiguration::resolveTracerProvider( + $options['openTelemetryTracerProvider'] + ); + // If logger is explicitly set to false, logging is disabled if (is_null($options['logger'])) { $options['logger'] = ApplicationDefaultCredentials::getDefaultLogger(); @@ -326,11 +336,25 @@ private function createCredentialsWrapper($credentials, array $credentialsConfig } if ($credentials instanceof FetchAuthTokenInterface) { - $authHttpHandler = $credentialsConfig['authHttpHandler'] ?? null; - return new CredentialsWrapper($credentials, $authHttpHandler, $universeDomain); + $authHttpHandler = CredentialsWrapper::wrapAuthHttpHandler( + $credentialsConfig['authHttpHandler'] ?? null, + $credentialsConfig['openTelemetryTracerProvider'] ?? null, + $credentialsConfig['clientVersion'] ?? '' + ); + return new CredentialsWrapper( + $credentials, + $authHttpHandler, + $universeDomain + ); } if ($credentials instanceof CredentialsWrapper) { + if (!empty($credentialsConfig['openTelemetryTracerProvider'])) { + $credentials->setOpenTelemetryTracerProvider( + $credentialsConfig['openTelemetryTracerProvider'], + $credentialsConfig['clientVersion'] ?? '' + ); + } return $credentials; } diff --git a/Gax/src/CredentialsWrapper.php b/Gax/src/CredentialsWrapper.php index ff9ccc9c24d7..dfcbaa4b51aa 100644 --- a/Gax/src/CredentialsWrapper.php +++ b/Gax/src/CredentialsWrapper.php @@ -33,6 +33,8 @@ use DomainException; use Exception; +use Google\ApiCore\Telemetry\AuthHttpHandler; +use Google\ApiCore\Telemetry\TelemetryConfiguration; use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Cache\MemoryCacheItemPool; use Google\Auth\Credentials\GCECredentials; @@ -42,8 +44,10 @@ use Google\Auth\FetchAuthTokenInterface; use Google\Auth\GetQuotaProjectInterface; use Google\Auth\GetUniverseDomainInterface; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\ProjectIdProviderInterface; use Google\Auth\UpdateMetadataInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; use Psr\Cache\CacheItemPoolInterface; /** @@ -70,6 +74,8 @@ class CredentialsWrapper implements HeaderCredentialsInterface, ProjectIdProvide * @param callable $authHttpHandler A handler used to deliver PSR-7 requests * specifically for authentication. Should match a signature of * `function (RequestInterface $request, array $options) : ResponseInterface`. + * @param string $universeDomain The expected universe of the credentials. Defaults to + * "googleapis.com" * @throws ValidationException */ public function __construct( @@ -138,8 +144,16 @@ public static function build( 'defaultScopes' => null, 'useJwtAccessWithScope' => true, 'enableRegionalAccessBoundary' => false, + 'openTelemetryTracerProvider' => null, + 'clientVersion' => '', ]; + $args['authHttpHandler'] = self::wrapAuthHttpHandler( + $args['authHttpHandler'], + $args['openTelemetryTracerProvider'], + $args['clientVersion'] + ); + $keyFile = $args['keyFile']; if (is_null($keyFile)) { @@ -190,7 +204,49 @@ public static function build( ); } - return new CredentialsWrapper($loader, $args['authHttpHandler'], $universeDomain); + return new CredentialsWrapper( + $loader, + $args['authHttpHandler'], + $universeDomain + ); + } + + /** + * @internal + * @param callable|null $authHttpHandler + * @param TracerProviderInterface|null $openTelemetryTracerProvider + * @param string $clientVersion + * @return callable|null + */ + public static function wrapAuthHttpHandler( + ?callable $authHttpHandler, + ?TracerProviderInterface $openTelemetryTracerProvider = null, + string $clientVersion = '' + ): ?callable { + $tracerProvider = TelemetryConfiguration::resolveTracerProvider($openTelemetryTracerProvider); + if ($tracerProvider && !($authHttpHandler instanceof AuthHttpHandler)) { + $handler = $authHttpHandler ?: HttpHandlerFactory::build(); + return new AuthHttpHandler($handler, $tracerProvider, $clientVersion); + } + return $authHttpHandler; + } + + /** + * @internal + * @param TracerProviderInterface|null $openTelemetryTracerProvider + * @param string $clientVersion + * @return $this + */ + public function setOpenTelemetryTracerProvider( + ?TracerProviderInterface $openTelemetryTracerProvider, + string $clientVersion = '' + ): self { + $this->authHttpHandler = self::wrapAuthHttpHandler( + $this->authHttpHandler, + $openTelemetryTracerProvider, + $clientVersion + ); + return $this; } /** diff --git a/Gax/src/GapicClientTrait.php b/Gax/src/GapicClientTrait.php index fd9d570353e5..fa2b179050c3 100644 --- a/Gax/src/GapicClientTrait.php +++ b/Gax/src/GapicClientTrait.php @@ -45,6 +45,7 @@ use Google\ApiCore\Options\ClientOptions; use Google\ApiCore\Options\TransportOptions; use Google\ApiCore\ResumableUpload\ResumableUpload; +use Google\ApiCore\Telemetry\SpanAttributes; use Google\ApiCore\Transport\GrpcFallbackTransport; use Google\ApiCore\Transport\GrpcTransport; use Google\ApiCore\Transport\RestTransport; @@ -53,6 +54,7 @@ use Google\LongRunning\Operation; use Google\Protobuf\Internal\Message; use GuzzleHttp\Promise\PromiseInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; /** * Common functions used to work with various clients. @@ -69,6 +71,9 @@ trait GapicClientTrait private ?TransportInterface $transport = null; private ?HeaderCredentialsInterface $credentialsWrapper = null; + /** @var TracerProviderInterface|null */ + private $openTelemetryTracerProvider; + private array $telemetryOptions = []; /** @var RetrySettings[] $retrySettings */ private array $retrySettings = []; private string $serviceName = ''; @@ -364,22 +369,40 @@ private function setClientOptions(array $options) $this->credentialsWrapper = $this->createCredentialsWrapper( $options['credentials'], $options['credentialsConfig'] + [ - 'enableRegionalAccessBoundary' => $enableRegionalAccessBoundary && !$isRegional + 'enableRegionalAccessBoundary' => $enableRegionalAccessBoundary && !$isRegional, + 'openTelemetryTracerProvider' => $options['openTelemetryTracerProvider'] ?? null, + 'clientVersion' => $options['libVersion'] ?? '', ], $options['universeDomain'], ); } + $this->openTelemetryTracerProvider = $options['openTelemetryTracerProvider'] ?? null; + $telemetryOptions = [ + 'openTelemetryTracerProvider' => $options['openTelemetryTracerProvider'] ?? null, + SpanAttributes::GCP_CLIENT_REPO => 'googleapis/google-cloud-php', + SpanAttributes::GCP_CLIENT_ARTIFACT => $options['clientPackageName'] ?? null, + SpanAttributes::GCP_CLIENT_SERVICE => $this->serviceName, + SpanAttributes::GCP_CLIENT_VERSION => $options['libVersion'] ?? null, + ]; + $this->telemetryOptions = $telemetryOptions; + $transport = $options['transport'] ?: self::defaultTransport(); - $this->transport = $transport instanceof TransportInterface - ? $transport - : $this->createTransport( + if ($transport instanceof TransportInterface) { + if (method_exists($transport, 'setTelemetryOptions')) { + $transport->setTelemetryOptions($telemetryOptions); + } + $this->transport = $transport; + } else { + $this->transport = $this->createTransport( $options['apiEndpoint'], $transport, $options['transportConfig'], $options['clientCertSource'], - $hasEmulator + $hasEmulator, + $telemetryOptions ); + } } /** @@ -396,7 +419,8 @@ private function createTransport( $transport, $transportConfig, ?callable $clientCertSource = null, - bool $hasEmulator = false + bool $hasEmulator = false, + array $telemetryOptions = [] ) { if (!is_string($transport)) { throw new ValidationException( @@ -419,6 +443,7 @@ private function createTransport( $configForSpecifiedTransport->setClientCertSource($clientCertSource); $configForSpecifiedTransport = $configForSpecifiedTransport->toArray(); } + $configForSpecifiedTransport += $telemetryOptions; switch ($transport) { case 'grpc': // Setting the user agent for gRPC requires special handling @@ -731,7 +756,15 @@ private function createCallStack(array $callConstructionOptions) $callStack = new CredentialsWrapperMiddleware($callStack, $this->credentialsWrapper); $callStack = new FixedHeaderMiddleware($callStack, $fixedHeaders, true); - $callStack = new RetryMiddleware($callStack, $callConstructionOptions['retrySettings']); + $callStack = new RetryMiddleware( + $callStack, + $callConstructionOptions['retrySettings'], + null, + 0, + null, + $this->openTelemetryTracerProvider ?? null, + $this->telemetryOptions + ); $callStack = new RequestAutoPopulationMiddleware( $callStack, $callConstructionOptions['autoPopulationSettings'], diff --git a/Gax/src/Middleware/RetryMiddleware.php b/Gax/src/Middleware/RetryMiddleware.php index 10603e2627df..ae155a42d23c 100644 --- a/Gax/src/Middleware/RetryMiddleware.php +++ b/Gax/src/Middleware/RetryMiddleware.php @@ -35,7 +35,12 @@ use Google\ApiCore\ApiStatus; use Google\ApiCore\Call; use Google\ApiCore\RetrySettings; +use Google\ApiCore\Telemetry\SpanAttributes; +use Google\ApiCore\Telemetry\TelemetryTrait; use GuzzleHttp\Promise\PromiseInterface; +use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\API\Trace\TracerProviderInterface; +use Throwable; /** * Middleware that adds retry functionality. @@ -44,6 +49,8 @@ */ class RetryMiddleware implements MiddlewareInterface { + use TelemetryTrait; + /** @var callable */ private $nextHandler; private RetrySettings $retrySettings; @@ -61,14 +68,17 @@ public function __construct( callable $nextHandler, RetrySettings $retrySettings, $deadlineMs = null, - $retryAttempts = 0, - ?callable $delayHandler = null + int $retryAttempts = 0, + ?callable $delayHandler = null, + ?TracerProviderInterface $openTelemetryTracerProvider = null, + array $telemetryOptions = [] ) { $this->nextHandler = $nextHandler; $this->retrySettings = $retrySettings; $this->deadlineMs = $deadlineMs; $this->retryAttempts = $retryAttempts; $this->delayHandler = ($delayHandler ?? [$this, 'sleepMillis']); + $this->initTelemetry($telemetryOptions, $openTelemetryTracerProvider); } /** @@ -169,13 +179,36 @@ private function retry(Call $call, array $options, string $status) $this->deadlineMs, $this->retryAttempts + 1, $this->delayHandler, + $this->openTelemetryTracerProvider, + $this->getTelemetryOptions() ); // Set the timeout for the call $options['timeoutMillis'] = $timeoutMs; - // Sleep for the length of the delay - ($this->delayHandler)((int) $delayMs); + $span = $this->startSpan('RetryDelay', [ + SpanAttributes::HTTP_REQUEST_RESEND_COUNT => $this->retryAttempts, + SpanAttributes::RPC_METHOD => $call->getMethod(), + ]); + $scope = $span ? $span->activate() : null; + + try { + // Sleep for the length of the delay + ($this->delayHandler)((int) $delayMs); + if ($span) { + $span->setStatus(StatusCode::STATUS_OK); + } + } catch (Throwable $ex) { + $this->recordException($span, $ex); + throw $ex; + } finally { + if ($scope) { + $scope->detach(); + } + if ($span) { + $span->end(); + } + } return $nextHandler( $call, diff --git a/Gax/src/Options/ClientOptions.php b/Gax/src/Options/ClientOptions.php index b9677fe20399..fdf4bd71de8f 100644 --- a/Gax/src/Options/ClientOptions.php +++ b/Gax/src/Options/ClientOptions.php @@ -1,4 +1,5 @@ setUniverseDomain($arr['universeDomain'] ?? null); $this->setApiKey($arr['apiKey'] ?? null); $this->setLogger($arr['logger'] ?? null); + $this->setOpenTelemetryTracerProvider($arr['openTelemetryTracerProvider'] ?? null); + $this->setClientPackageName($arr['clientPackageName'] ?? null); } /** @@ -418,4 +428,52 @@ public function setLogger(null|false|LoggerInterface $logger): self return $this; } + + /** + * @internal + * + * @param TracerProviderInterface|null $openTelemetryTracerProvider + * + * @return $this + */ + public function setOpenTelemetryTracerProvider(?TracerProviderInterface $openTelemetryTracerProvider): self + { + $this->openTelemetryTracerProvider = $openTelemetryTracerProvider; + + return $this; + } + + /** + * @internal + * + * @return TracerProviderInterface|null + */ + public function getOpenTelemetryTracerProvider(): ?TracerProviderInterface + { + return $this->openTelemetryTracerProvider; + } + + /** + * @internal + * + * @param string|null $clientPackageName + * + * @return $this + */ + public function setClientPackageName(?string $clientPackageName): self + { + $this->clientPackageName = $clientPackageName; + + return $this; + } + + /** + * @internal + * + * @return string|null + */ + public function getClientPackageName(): ?string + { + return $this->clientPackageName; + } } diff --git a/Gax/src/Telemetry/AuthHttpHandler.php b/Gax/src/Telemetry/AuthHttpHandler.php new file mode 100644 index 000000000000..5c17e71a8676 --- /dev/null +++ b/Gax/src/Telemetry/AuthHttpHandler.php @@ -0,0 +1,153 @@ +httpHandler = $httpHandler; + $this->initTelemetry([ + SpanAttributes::GCP_CLIENT_REPO => 'googleapis/google-cloud-php', + SpanAttributes::GCP_CLIENT_VERSION => $clientVersion, + ], $tracerProvider); + } + + /** + * Execute the request, wrapping it in an AuthenticationRefresh span if tracing is enabled. + * + * @param RequestInterface $request + * @param array $options + * @return ResponseInterface|PromiseInterface + * @throws Throwable + */ + public function __invoke(RequestInterface $request, array $options = []) + { + if (!$this->openTelemetryTracerProvider) { + $response = ($this->httpHandler)($request, $options); + if ($response instanceof ResponseInterface || $response instanceof PromiseInterface) { + return $response; + } + + throw new UnexpectedValueException( + 'HTTP handler must return an instance of ResponseInterface or PromiseInterface' + ); + } + + $tracer = $this->openTelemetryTracerProvider->getTracer('google-cloud-php', $this->clientVersion); + $spanBuilder = $tracer->spanBuilder('AuthenticationRefresh') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(SpanAttributes::GCP_CLIENT_REPO, 'googleapis/google-cloud-php') + ->setAttribute(SpanAttributes::HTTP_REQUEST_METHOD, $request->getMethod()) + ->setAttribute(SpanAttributes::URL_FULL, (string) $request->getUri()); + + $uri = $request->getUri(); + $host = $uri->getHost(); + if ($host) { + $spanBuilder->setAttribute(SpanAttributes::SERVER_ADDRESS, $host); + $spanBuilder->setAttribute(SpanAttributes::URL_DOMAIN, $host); + } + $scheme = $uri->getScheme(); + $defaultPort = $scheme === 'https' ? 443 : ($scheme === 'http' ? 80 : null); + $port = $uri->getPort() ?: $defaultPort; + if ($port) { + $spanBuilder->setAttribute(SpanAttributes::SERVER_PORT, $port); + } + + $span = $spanBuilder->startSpan(); + $scope = $span->activate(); + + $recordSuccess = function (ResponseInterface $res) use ($span): ResponseInterface { + $span->setStatus(StatusCode::STATUS_OK); + $span->setAttribute(SpanAttributes::HTTP_RESPONSE_STATUS_CODE, $res->getStatusCode()); + $span->end(); + return $res; + }; + + $recordFailure = function (Throwable $e) use ($span) { + $this->recordException($span, $e, true); + throw $e; + }; + + try { + $response = ($this->httpHandler)($request, $options); + if ($response instanceof PromiseInterface) { + return $response->then($recordSuccess, $recordFailure); + } + + if ($response instanceof ResponseInterface) { + return $recordSuccess($response); + } + + throw new UnexpectedValueException( + 'HTTP handler must return an instance of ResponseInterface or PromiseInterface' + ); + } catch (Throwable $e) { + $this->recordException($span, $e, true); + throw $e; + } finally { + $scope->detach(); + } + } +} diff --git a/Gax/src/Telemetry/SpanAttributes.php b/Gax/src/Telemetry/SpanAttributes.php new file mode 100644 index 000000000000..04ec1e190263 --- /dev/null +++ b/Gax/src/Telemetry/SpanAttributes.php @@ -0,0 +1,70 @@ +initTelemetry($telemetryOptions, $openTelemetryTracerProvider); + return $this; + } + + /** + * Initializes telemetry properties from an options array and optional tracer provider. + * + * @param array $telemetryOptions + * @param TracerProviderInterface|null $openTelemetryTracerProvider + */ + private function initTelemetry( + array $telemetryOptions, + ?TracerProviderInterface $openTelemetryTracerProvider = null + ): void { + $this->openTelemetryTracerProvider = $openTelemetryTracerProvider + ?? $telemetryOptions['openTelemetryTracerProvider'] + ?? null; + $this->clientRepo = $telemetryOptions[SpanAttributes::GCP_CLIENT_REPO] ?? ''; + $this->clientArtifact = $telemetryOptions[SpanAttributes::GCP_CLIENT_ARTIFACT] ?? ''; + $this->clientService = $telemetryOptions[SpanAttributes::GCP_CLIENT_SERVICE] ?? ''; + $this->clientVersion = $telemetryOptions[SpanAttributes::GCP_CLIENT_VERSION] ?? ''; + } + + /** + * Returns default telemetry config options for transport build methods. + * + * @return array + */ + private static function getTelemetryDefaultConfig(): array + { + return [ + 'openTelemetryTracerProvider' => null, + SpanAttributes::GCP_CLIENT_REPO => '', + SpanAttributes::GCP_CLIENT_ARTIFACT => '', + SpanAttributes::GCP_CLIENT_SERVICE => '', + SpanAttributes::GCP_CLIENT_VERSION => '', + ]; + } + + /** + * Returns the telemetry options populated from this instance. + * + * @return array + */ + private function getTelemetryOptions(): array + { + return [ + SpanAttributes::GCP_CLIENT_REPO => $this->clientRepo, + SpanAttributes::GCP_CLIENT_ARTIFACT => $this->clientArtifact, + SpanAttributes::GCP_CLIENT_SERVICE => $this->clientService, + SpanAttributes::GCP_CLIENT_VERSION => $this->clientVersion, + ]; + } + + /** + * Builds and starts an internal span with standard client metadata attributes. + * + * @param string $spanName + * @param array $attributes + * @return SpanInterface|null + */ + private function startSpan(string $spanName, array $attributes = []): ?SpanInterface + { + if (!$this->openTelemetryTracerProvider) { + return null; + } + + $tracer = $this->openTelemetryTracerProvider->getTracer('google-cloud-php', $this->clientVersion); + $spanBuilder = $tracer->spanBuilder($spanName) + ->setSpanKind(SpanKind::KIND_INTERNAL); + + if ($this->clientRepo) { + $spanBuilder->setAttribute(SpanAttributes::GCP_CLIENT_REPO, $this->clientRepo); + } + if ($this->clientArtifact) { + $spanBuilder->setAttribute(SpanAttributes::GCP_CLIENT_ARTIFACT, $this->clientArtifact); + } + if ($this->clientService) { + $spanBuilder->setAttribute(SpanAttributes::GCP_CLIENT_SERVICE, $this->clientService); + } + if ($this->clientVersion) { + $spanBuilder->setAttribute(SpanAttributes::GCP_CLIENT_VERSION, $this->clientVersion); + } + + foreach ($attributes as $key => $value) { + $spanBuilder->setAttribute($key, $value); + } + + return $spanBuilder->startSpan(); + } + + /** + * Starts an internal transport span with standard client metadata attributes. + * + * @param string $spanName + * @param Call $call + * @return SpanInterface|null + */ + private function startTransportSpan(string $spanName, Call $call): ?SpanInterface + { + return $this->startSpan($spanName, [ + SpanAttributes::RPC_METHOD => $call->getMethod(), + SpanAttributes::RPC_SYSTEM => 'http', + ]); + } + + /** + * Records error status and attributes on a span from a Throwable. + * + * @param SpanInterface|null $span + * @param Throwable $e + * @param bool $end + */ + private function recordException(?SpanInterface $span, Throwable $e, bool $end = false): void + { + if ($span === null) { + return; + } + + $statusCode = null; + if (method_exists($e, 'getResponse') && $e->getResponse() instanceof ResponseInterface) { + $statusCode = $e->getResponse()->getStatusCode(); + $span->setAttribute(SpanAttributes::HTTP_RESPONSE_STATUS_CODE, $statusCode); + } + + $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); + $span->setAttribute(SpanAttributes::ERROR_TYPE, $statusCode ? (string) $statusCode : get_class($e)); + $span->setAttribute(SpanAttributes::EXCEPTION_TYPE, get_class($e)); + $span->setAttribute(SpanAttributes::STATUS_MESSAGE, $e->getMessage()); + + if ($end) { + $span->end(); + } + } +} diff --git a/Gax/src/Transport/GrpcFallbackTransport.php b/Gax/src/Transport/GrpcFallbackTransport.php index 149cf476f32d..482d23c94c55 100644 --- a/Gax/src/Transport/GrpcFallbackTransport.php +++ b/Gax/src/Transport/GrpcFallbackTransport.php @@ -35,14 +35,17 @@ use Google\ApiCore\ApiStatus; use Google\ApiCore\Call; use Google\ApiCore\ServiceAddressTrait; +use Google\ApiCore\Telemetry\TelemetryTrait; use Google\ApiCore\ValidationException; use Google\ApiCore\ValidationTrait; use Google\Protobuf\Internal\Message; use Google\Rpc\Status; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; +use OpenTelemetry\API\Trace\StatusCode; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Throwable; /** * A transport that sends protobuf over HTTP 1.1 that can be used when full gRPC support @@ -52,12 +55,13 @@ class GrpcFallbackTransport implements TransportInterface { use ValidationTrait; use ServiceAddressTrait; + use TelemetryTrait; use HttpUnaryTransportTrait; private string $baseUri; /** - * @param string $baseUri + * @param string $baseUri The base URI to connect to. * @param callable $httpHandler A handler used to deliver PSR-7 requests. */ public function __construct( @@ -88,10 +92,11 @@ public static function build(string $apiEndpoint, array $config = []) 'httpHandler' => null, 'clientCertSource' => null, 'logger' => null, - ]; + ] + self::getTelemetryDefaultConfig(); list($baseUri, $port) = self::normalizeServiceAddress($apiEndpoint); $httpHandler = $config['httpHandler'] ?: self::buildHttpHandlerAsync(logger: $config['logger']); $transport = new GrpcFallbackTransport("$baseUri:$port", $httpHandler); + $transport->setTelemetryOptions($config); if ($config['clientCertSource']) { $transport->configureMtlsChannel($config['clientCertSource']); } @@ -107,8 +112,24 @@ public function startUnaryCall(Call $call, array $options) $options['requestId'] = crc32((string) spl_object_id($call) . getmypid()); + $spanMarshaling = $this->startTransportSpan('RequestMarshaling', $call); + + try { + $request = $this->buildGrpcFallbackRequest($call, $options); + if ($spanMarshaling) { + $spanMarshaling->setStatus(StatusCode::STATUS_OK); + } + } catch (Throwable $ex) { + $this->recordException($spanMarshaling, $ex); + throw $ex; + } finally { + if ($spanMarshaling) { + $spanMarshaling->end(); + } + } + return $httpHandler( - $this->buildGrpcFallbackRequest($call, $options), + $request, $this->getCallOptions($options) )->then( function (ResponseInterface $response) use ($options) { @@ -158,14 +179,29 @@ private function buildGrpcFallbackRequest(Call $call, array $options) * @param Call $call * @param ResponseInterface $response * @return Message + * @throws Throwable */ private function unpackResponse(Call $call, ResponseInterface $response) { - $decodeType = $call->getDecodeType(); - /** @var Message $responseMessage */ - $responseMessage = new $decodeType(); - $responseMessage->mergeFromString((string) $response->getBody()); - return $responseMessage; + $spanUnmarshaling = $this->startTransportSpan('ResponseUnmarshaling', $call); + + try { + $decodeType = $call->getDecodeType(); + /** @var Message $responseMessage */ + $responseMessage = new $decodeType(); + $responseMessage->mergeFromString((string) $response->getBody()); + if ($spanUnmarshaling) { + $spanUnmarshaling->setStatus(StatusCode::STATUS_OK); + } + return $responseMessage; + } catch (Throwable $ex) { + $this->recordException($spanUnmarshaling, $ex); + throw $ex; + } finally { + if ($spanUnmarshaling) { + $spanUnmarshaling->end(); + } + } } /** diff --git a/Gax/src/Transport/RestTransport.php b/Gax/src/Transport/RestTransport.php index ae0245130dd9..090ae1cd39b2 100644 --- a/Gax/src/Transport/RestTransport.php +++ b/Gax/src/Transport/RestTransport.php @@ -1,4 +1,5 @@ null, 'hasEmulator' => false, 'logger' => null, - ]; + ] + self::getTelemetryDefaultConfig(); list($baseUri, $port) = self::normalizeServiceAddress($apiEndpoint); $requestBuilder = $config['hasEmulator'] ? new InsecureRequestBuilder("$baseUri:$port", $restConfigPath) : new RequestBuilder("$baseUri:$port", $restConfigPath); $httpHandler = $config['httpHandler'] ?: self::buildHttpHandlerAsync($config['logger']); $transport = new RestTransport($requestBuilder, $httpHandler); + $transport->setTelemetryOptions($config); if ($config['clientCertSource']) { $transport->configureMtlsChannel($config['clientCertSource']); } @@ -120,46 +128,78 @@ public function startUnaryCall(Call $call, array $options) // Add the $call object ID for logging $options['requestId'] = crc32((string) spl_object_id($call) . getmypid()); - // call the HTTP handler - $httpHandler = $this->httpHandler; - return $httpHandler( - $this->requestBuilder->build( + $spanMarshaling = $this->startTransportSpan('RequestMarshaling', $call); + + try { + $request = $this->requestBuilder->build( $call->getMethod(), $call->getMessage(), $headers - ), + ); + if ($spanMarshaling) { + $spanMarshaling->setStatus(StatusCode::STATUS_OK); + } + } catch (Throwable $ex) { + $this->recordException($spanMarshaling, $ex); + throw $ex; + } finally { + if ($spanMarshaling) { + $spanMarshaling->end(); + } + } + + // call the HTTP handler + $httpHandler = $this->httpHandler; + $promise = $httpHandler( + $request, $this->getCallOptions($options) - )->then( + ); + + return $promise->then( function (ResponseInterface $response) use ($call, $options) { $decodeType = $call->getDecodeType(); /** @var Message $return */ $return = new $decodeType(); $body = (string) $response->getBody(); + $spanUnmarshaling = $this->startTransportSpan('ResponseUnmarshaling', $call); + // In some rare cases LRO response metadata may not be loaded // in the descriptor pool, triggering an exception. The catch // statement handles this case and attempts to add the LRO // metadata type to the pool by directly instantiating the // metadata class. try { - $return->mergeFromJsonString( - $body, - true - ); - } catch (\Exception $ex) { - if (!isset($options['metadataReturnType'])) { - throw $ex; - } + try { + $return->mergeFromJsonString( + $body, + true + ); + } catch (Exception $ex) { + if (!isset($options['metadataReturnType'])) { + throw $ex; + } - if (strpos($ex->getMessage(), 'Error occurred during parsing:') !== 0) { - throw $ex; - } + if (strpos($ex->getMessage(), 'Error occurred during parsing:') !== 0) { + throw $ex; + } - new $options['metadataReturnType'](); - $return->mergeFromJsonString( - $body, - true - ); + new $options['metadataReturnType'](); + $return->mergeFromJsonString( + $body, + true + ); + } + if ($spanUnmarshaling) { + $spanUnmarshaling->setStatus(StatusCode::STATUS_OK); + } + } catch (Throwable $ex) { + $this->recordException($spanUnmarshaling, $ex); + throw $ex; + } finally { + if ($spanUnmarshaling) { + $spanUnmarshaling->end(); + } } if (isset($options['metadataCallback'])) { diff --git a/Gax/tests/Unit/AgentHeaderTest.php b/Gax/tests/Unit/AgentHeaderTest.php index 69dc6c74b2c6..24bf20004fed 100644 --- a/Gax/tests/Unit/AgentHeaderTest.php +++ b/Gax/tests/Unit/AgentHeaderTest.php @@ -34,6 +34,7 @@ use Google\ApiCore\AgentHeader; use Google\ApiCore\Version; use PHPUnit\Framework\TestCase; +use stdClass; class AgentHeaderTest extends TestCase { @@ -164,4 +165,15 @@ public function testWithRestAndGaxFallback() $this->assertSame($expectedHeader, $header); } + + public function testReadPackageNameFromFile() + { + $packageName = AgentHeader::readPackageNameFromFile(AgentHeader::class); + $this->assertSame('google/gax', $packageName); + } + + public function testReadPackageNameFromUnknownClassReturnsNull() + { + $this->assertNull(AgentHeader::readPackageNameFromFile(stdClass::class)); + } } diff --git a/Gax/tests/Unit/ClientOptionsTraitTest.php b/Gax/tests/Unit/ClientOptionsTraitTest.php index a5c9d964a57f..54589b4860cc 100644 --- a/Gax/tests/Unit/ClientOptionsTraitTest.php +++ b/Gax/tests/Unit/ClientOptionsTraitTest.php @@ -35,6 +35,7 @@ use Google\ApiCore\ClientOptionsTrait; use Google\ApiCore\CredentialsWrapper; use Google\ApiCore\Options\ClientOptions; +use Google\ApiCore\Telemetry\AuthHttpHandler; use Google\ApiCore\ValidationException; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; @@ -43,9 +44,11 @@ use Grpc\Gcp\ApiConfig; use Grpc\Gcp\Config; use InvalidArgumentException; +use OpenTelemetry\API\Trace\TracerProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\LogLevel; +use ReflectionClass; class ClientOptionsTraitTest extends TestCase { @@ -71,7 +74,7 @@ public function setUp(): void public function set($name, $val, $static = false) { if (!property_exists($this, $name)) { - throw new \InvalidArgumentException("Property not found: $name"); + throw new InvalidArgumentException("Property not found: $name"); } if ($static) { $this::$$name = $val; @@ -228,6 +231,24 @@ public function createCredentialsWrapperInvalidArgumentExceptionData() ]; } + public function testCreateCredentialsWrapperWithPreInstantiatedWrapperAndTracing() + { + $fetcher = $this->prophesize(FetchAuthTokenInterface::class)->reveal(); + $credentialsWrapper = new CredentialsWrapper($fetcher); + $tracerProvider = $this->createMock(TracerProviderInterface::class); + + $result = $this->clientStub->createCredentialsWrapper( + $credentialsWrapper, + ['openTelemetryTracerProvider' => $tracerProvider, 'clientVersion' => '1.0.0'], + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN + ); + + $this->assertSame($credentialsWrapper, $result); + $reflection = new ReflectionClass($result); + $prop = $reflection->getProperty('authHttpHandler'); + $this->assertInstanceOf(AuthHttpHandler::class, $prop->getValue($result)); + } + /** * @dataProvider buildClientOptionsProvider */ @@ -276,6 +297,8 @@ public function buildClientOptionsProvider() 'clientCertSource' => null, 'logger' => null, 'universeDomain' => 'googleapis.com', + 'clientPackageName' => null, + 'openTelemetryTracerProvider' => null, ]; $restConfigOptions = $defaultOptions; @@ -357,7 +380,9 @@ public function buildClientOptionsProviderRestOnly() 'libVersion' => null, 'clientCertSource' => null, 'universeDomain' => 'googleapis.com', - 'logger' => null + 'logger' => null, + 'clientPackageName' => null, + 'openTelemetryTracerProvider' => null, ]; $restConfigOptions = $defaultOptions; @@ -747,4 +772,23 @@ public function testExceptionIsRaisedIfOptionsIsInvalid() ]; $this->clientStub->buildClientOptions($optionsArray); } + + public function testClientPackageNameOptionExplicit() + { + $optionsArray = [ + 'clientPackageName' => 'google/cloud-secret-manager' + ]; + $options = $this->clientStub->buildClientOptions($optionsArray); + $this->assertSame('google/cloud-secret-manager', $options['clientPackageName']); + } + + public function testOpenTelemetryTracerProviderOption() + { + $mockProvider = $this->createMock(TracerProviderInterface::class); + $optionsArray = [ + 'openTelemetryTracerProvider' => $mockProvider + ]; + $options = $this->clientStub->buildClientOptions($optionsArray); + $this->assertSame($mockProvider, $options['openTelemetryTracerProvider']); + } } diff --git a/Gax/tests/Unit/CredentialsWrapperTest.php b/Gax/tests/Unit/CredentialsWrapperTest.php index 428957daf1b1..4dd4239d21ac 100644 --- a/Gax/tests/Unit/CredentialsWrapperTest.php +++ b/Gax/tests/Unit/CredentialsWrapperTest.php @@ -33,6 +33,7 @@ namespace Google\ApiCore\Tests\Unit; use Google\ApiCore\CredentialsWrapper; +use Google\ApiCore\Telemetry\AuthHttpHandler; use Google\ApiCore\ValidationException; use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Cache\MemoryCacheItemPool; @@ -47,9 +48,11 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\ProjectIdProviderInterface; use Google\Auth\UpdateMetadataInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use ReflectionClass; class CredentialsWrapperTest extends TestCase { @@ -643,6 +646,77 @@ public function testSerializeCredentialsWrapper() $this->assertIsString($serialized); } + public function testSetOpenTelemetryTracerProviderWrapsAuthHttpHandler() + { + $credentials = $this->createMock(FetchAuthTokenInterface::class); + $tracerProvider = $this->createMock(TracerProviderInterface::class); + + $wrapper = new CredentialsWrapper( + $credentials, + null, + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN + ); + $wrapper->setOpenTelemetryTracerProvider($tracerProvider, '1.0.0'); + + $reflection = new ReflectionClass($wrapper); + $property = $reflection->getProperty('authHttpHandler'); + $handler = $property->getValue($wrapper); + + $this->assertInstanceOf(AuthHttpHandler::class, $handler); + } + + public function testConstructorDoesNotWrapWhenTracingDisabled() + { + $credentials = $this->createMock(FetchAuthTokenInterface::class); + + $wrapper = new CredentialsWrapper($credentials); + + $reflection = new ReflectionClass($wrapper); + $property = $reflection->getProperty('authHttpHandler'); + $handler = $property->getValue($wrapper); + + $this->assertNull($handler); + } + + public function testBuildWrapsAuthHttpHandlerWithTracing() + { + $tracerProvider = $this->createMock(TracerProviderInterface::class); + + $wrapper = CredentialsWrapper::build([ + 'keyFile' => __DIR__ . '/testdata/creds/json-key-file.json', + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientVersion' => '1.0.0', + ]); + + $reflection = new ReflectionClass($wrapper); + $property = $reflection->getProperty('authHttpHandler'); + $handler = $property->getValue($wrapper); + + $this->assertInstanceOf(AuthHttpHandler::class, $handler); + } + + public function testConstructorDoesNotDoubleWrap() + { + $credentials = $this->createMock(FetchAuthTokenInterface::class); + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $dummyInner = function () { + }; + $authHttpHandler = new AuthHttpHandler($dummyInner, $tracerProvider, '1.0.0'); + + $wrapper = new CredentialsWrapper( + $credentials, + $authHttpHandler, + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN + ); + $wrapper->setOpenTelemetryTracerProvider($tracerProvider, '1.0.0'); + + $reflection = new ReflectionClass($wrapper); + $property = $reflection->getProperty('authHttpHandler'); + $handler = $property->getValue($wrapper); + + $this->assertSame($authHttpHandler, $handler); + } + private function setEnv(string $env, ?string $value = null) { if ($value === null) { diff --git a/Gax/tests/Unit/GapicClientTraitTest.php b/Gax/tests/Unit/GapicClientTraitTest.php index 17f6ea68e0b0..8785dc2a0e30 100644 --- a/Gax/tests/Unit/GapicClientTraitTest.php +++ b/Gax/tests/Unit/GapicClientTraitTest.php @@ -45,6 +45,7 @@ use Google\ApiCore\RequestParamsHeaderDescriptor; use Google\ApiCore\RetrySettings; use Google\ApiCore\ServerStream; +use Google\ApiCore\Telemetry\SpanAttributes; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Testing\MockRequestBody; use Google\ApiCore\Testing\MockResponse; @@ -58,6 +59,7 @@ use Grpc\Gcp\Config; use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\PromiseInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -1963,6 +1965,52 @@ public function testGetServiceScopes() DefaultScopeAndAudienceGapicClient::getServiceScopes() ); } + + public function testPreInstantiatedTransportReceivesTelemetryOptions() + { + $transport = new class() implements TransportInterface { + public ?array $telemetryOptions = null; + + public function setTelemetryOptions(array $telemetryOptions): void + { + $this->telemetryOptions = $telemetryOptions; + } + + public function startUnaryCall(Call $call, array $options) + { + } + + public function startServerStreamingCall(Call $call, array $options) + { + } + + public function startClientStreamingCall(Call $call, array $options) + { + } + + public function startBidiStreamingCall(Call $call, array $options) + { + } + + public function close() + { + } + }; + + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $client = new StubGapicClient(); + $options = $client->buildClientOptions([ + 'transport' => $transport, + 'openTelemetryTracerProvider' => $tracerProvider, + 'clientPackageName' => 'google/cloud-secret-manager', + ]); + $client->setClientOptions($options); + + $this->assertSame($transport, $client->getTransport()); + $this->assertNotNull($transport->telemetryOptions); + $this->assertSame($tracerProvider, $transport->telemetryOptions['openTelemetryTracerProvider']); + $this->assertSame('google/cloud-secret-manager', $transport->telemetryOptions[SpanAttributes::GCP_CLIENT_ARTIFACT]); + } } class StubGapicClient diff --git a/Gax/tests/Unit/Middleware/RetryMiddlewareTest.php b/Gax/tests/Unit/Middleware/RetryMiddlewareTest.php index 0f87a4568084..9db4fe15cf4e 100644 --- a/Gax/tests/Unit/Middleware/RetryMiddlewareTest.php +++ b/Gax/tests/Unit/Middleware/RetryMiddlewareTest.php @@ -37,11 +37,20 @@ use Google\ApiCore\Call; use Google\ApiCore\Middleware\RetryMiddleware; use Google\ApiCore\RetrySettings; +use Google\ApiCore\Telemetry\SpanAttributes; use Google\Rpc\Code; +use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\Promise; +use GuzzleHttp\Promise\RejectedPromise; +use OpenTelemetry\API\Trace\SpanBuilderInterface; +use OpenTelemetry\API\Trace\SpanInterface; +use OpenTelemetry\API\Trace\SpanKind; +use OpenTelemetry\API\Trace\TracerInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; +use OpenTelemetry\Context\ScopeInterface; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use function usleep; class RetryMiddlewareTest extends TestCase @@ -565,4 +574,100 @@ public function testDelayCount() $this->assertCount(3, $delays); $this->assertEquals([100, 130, 169], $delays); } + public function testRetryMiddlewareTracerProvider() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $openTelemetryTracerProvider->expects($this->once()) + ->method('getTracer') + ->with('google-cloud-php', '1.0.0') + ->willReturn($tracer); + + $tracer->expects($this->once()) + ->method('spanBuilder') + ->with('RetryDelay') + ->willReturn($spanBuilder); + + $spanBuilder->expects($this->once()) + ->method('setSpanKind') + ->with(SpanKind::KIND_INTERNAL) + ->willReturnSelf(); + + $attributes = []; + $spanBuilder->method('setAttribute') + ->willReturnCallback(function ($key, $val) use (&$attributes, $spanBuilder) { + $attributes[$key] = $val; + return $spanBuilder; + }); + + $spanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($span); + + $span->expects($this->once()) + ->method('activate') + ->willReturn($scope); + + $span->expects($this->once()) + ->method('end'); + + $scope->expects($this->once()) + ->method('detach'); + + $retrySettings = RetrySettings::constructDefault()->with([ + 'retriesEnabled' => true, + 'retryableCodes' => [Code::UNAVAILABLE], + ]); + $delayHandlerCalled = false; + $delayHandler = function ($delay) use (&$delayHandlerCalled) { + $delayHandlerCalled = true; + }; + + $nextHandlerCalled = 0; + $nextHandler = function ($call, $options) use (&$nextHandlerCalled) { + $nextHandlerCalled++; + if ($nextHandlerCalled === 1) { + return new RejectedPromise( + new ApiException('test', 14, Code::UNAVAILABLE) + ); + } + return new FulfilledPromise('success'); + }; + + $telemetryOptions = [ + SpanAttributes::GCP_CLIENT_REPO => 'googleapis/google-cloud-php', + SpanAttributes::GCP_CLIENT_ARTIFACT => 'google-cloud-secretmanager', + SpanAttributes::GCP_CLIENT_SERVICE => 'secretmanager', + SpanAttributes::GCP_CLIENT_VERSION => '1.0.0', + ]; + + $middleware = new RetryMiddleware( + $nextHandler, + $retrySettings, + null, + 0, + $delayHandler, + $openTelemetryTracerProvider, + $telemetryOptions + ); + + $method = 'google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion'; + $call = $this->prophesize(Call::class); + $call->getMethod()->willReturn($method); + $promise = $middleware($call->reveal(), []); + $promise->wait(); + + $this->assertTrue($delayHandlerCalled); + $this->assertEquals(2, $nextHandlerCalled); + $this->assertEquals(0, $attributes[SpanAttributes::HTTP_REQUEST_RESEND_COUNT]); + $this->assertEquals($method, $attributes[SpanAttributes::RPC_METHOD]); + $this->assertEquals('googleapis/google-cloud-php', $attributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertEquals('google-cloud-secretmanager', $attributes[SpanAttributes::GCP_CLIENT_ARTIFACT]); + $this->assertEquals('secretmanager', $attributes[SpanAttributes::GCP_CLIENT_SERVICE]); + $this->assertEquals('1.0.0', $attributes[SpanAttributes::GCP_CLIENT_VERSION]); + } } diff --git a/Gax/tests/Unit/Options/ClientOptionsTest.php b/Gax/tests/Unit/Options/ClientOptionsTest.php new file mode 100644 index 000000000000..a59c938df0cf --- /dev/null +++ b/Gax/tests/Unit/Options/ClientOptionsTest.php @@ -0,0 +1,53 @@ +createMock(TracerProviderInterface::class); + $options = new ClientOptions([]); + $options->setOpenTelemetryTracerProvider($openTelemetryTracerProvider); + $this->assertSame($openTelemetryTracerProvider, $options->getOpenTelemetryTracerProvider()); + } + + public function testConstructorInjection() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + + $options = new ClientOptions([ + 'openTelemetryTracerProvider' => $openTelemetryTracerProvider, + 'clientPackageName' => 'google/cloud-secret-manager', + ]); + + $this->assertSame($openTelemetryTracerProvider, $options->getOpenTelemetryTracerProvider()); + $this->assertSame('google/cloud-secret-manager', $options->getClientPackageName()); + } + + public function testSetAndGetClientPackageName() + { + $options = new ClientOptions([]); + $options->setClientPackageName('google/cloud-secret-manager'); + $this->assertSame('google/cloud-secret-manager', $options->getClientPackageName()); + } +} diff --git a/Gax/tests/Unit/Telemetry/AuthHttpHandlerTest.php b/Gax/tests/Unit/Telemetry/AuthHttpHandlerTest.php new file mode 100644 index 000000000000..354d6753e918 --- /dev/null +++ b/Gax/tests/Unit/Telemetry/AuthHttpHandlerTest.php @@ -0,0 +1,345 @@ + 5]); + + $this->assertTrue($called); + $this->assertSame($expectedResponse, $response); + } + + public function testSyncSuccess() + { + $expectedResponse = new Response(200, [], '{"access_token": "xyz"}'); + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $tracerProvider->expects($this->once()) + ->method('getTracer') + ->with('google-cloud-php', '1.0.0') + ->willReturn($tracer); + + $tracer->expects($this->once()) + ->method('spanBuilder') + ->with('AuthenticationRefresh') + ->willReturn($spanBuilder); + + $attributes = []; + $spanBuilder->expects($this->once()) + ->method('setSpanKind') + ->with(SpanKind::KIND_CLIENT) + ->willReturnSelf(); + $spanBuilder->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$attributes, $spanBuilder) { + $attributes[$key] = $value; + return $spanBuilder; + }); + $spanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($span); + + $span->expects($this->once()) + ->method('activate') + ->willReturn($scope); + + $spanAttributes = []; + $span->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$spanAttributes, $span) { + $spanAttributes[$key] = $value; + return $span; + }); + $span->expects($this->once()) + ->method('setStatus') + ->with(StatusCode::STATUS_OK); + $scope->expects($this->once()) + ->method('detach'); + $span->expects($this->once()) + ->method('end'); + + $innerHandler = function (RequestInterface $request, array $options) use ($expectedResponse) { + return $expectedResponse; + }; + + $handler = new AuthHttpHandler($innerHandler, $tracerProvider, '1.0.0'); + $request = new Request('POST', 'https://oauth2.googleapis.com/token'); + $response = $handler($request); + + $this->assertSame($expectedResponse, $response); + $this->assertSame('googleapis/google-cloud-php', $attributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertSame('POST', $attributes[SpanAttributes::HTTP_REQUEST_METHOD]); + $this->assertSame('https://oauth2.googleapis.com/token', $attributes[SpanAttributes::URL_FULL]); + $this->assertSame('oauth2.googleapis.com', $attributes[SpanAttributes::SERVER_ADDRESS]); + $this->assertSame('oauth2.googleapis.com', $attributes[SpanAttributes::URL_DOMAIN]); + $this->assertSame(443, $attributes[SpanAttributes::SERVER_PORT]); + $this->assertSame(200, $spanAttributes[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + } + + public function testSyncFailure() + { + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $tracerProvider->method('getTracer')->willReturn($tracer); + $tracer->method('spanBuilder')->willReturn($spanBuilder); + $spanBuilder->method('setSpanKind')->willReturnSelf(); + $spanBuilder->method('setAttribute')->willReturnSelf(); + $spanBuilder->method('startSpan')->willReturn($span); + $span->method('activate')->willReturn($scope); + + $spanAttributes = []; + $span->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$spanAttributes, $span) { + $spanAttributes[$key] = $value; + return $span; + }); + $span->expects($this->once()) + ->method('setStatus') + ->with(StatusCode::STATUS_ERROR, 'Connection refused'); + $scope->expects($this->once()) + ->method('detach'); + $span->expects($this->once()) + ->method('end'); + + $innerHandler = function () { + throw new RuntimeException('Connection refused'); + }; + + $handler = new AuthHttpHandler($innerHandler, $tracerProvider); + $request = new Request('POST', 'https://oauth2.googleapis.com/token'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Connection refused'); + + try { + $handler($request); + } finally { + $this->assertSame(RuntimeException::class, $spanAttributes[SpanAttributes::ERROR_TYPE]); + $this->assertSame(RuntimeException::class, $spanAttributes[SpanAttributes::EXCEPTION_TYPE]); + $this->assertSame('Connection refused', $spanAttributes[SpanAttributes::STATUS_MESSAGE]); + } + } + + public function testSyncFailureWithResponse() + { + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $tracerProvider->method('getTracer')->willReturn($tracer); + $tracer->method('spanBuilder')->willReturn($spanBuilder); + $spanBuilder->method('setSpanKind')->willReturnSelf(); + $spanBuilder->method('setAttribute')->willReturnSelf(); + $spanBuilder->method('startSpan')->willReturn($span); + $span->method('activate')->willReturn($scope); + + $spanAttributes = []; + $span->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$spanAttributes, $span) { + $spanAttributes[$key] = $value; + return $span; + }); + $span->expects($this->once()) + ->method('setStatus') + ->with(StatusCode::STATUS_ERROR, 'Unauthorized'); + $scope->expects($this->once()) + ->method('detach'); + $span->expects($this->once()) + ->method('end'); + + $errorResponse = new Response(401, [], '{"error": "invalid_grant"}'); + $exception = new class ('Unauthorized', $errorResponse) extends Exception { + private ResponseInterface $response; + + public function __construct(string $message, ResponseInterface $response) + { + parent::__construct($message); + $this->response = $response; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + }; + + $innerHandler = function () use ($exception) { + throw $exception; + }; + + $handler = new AuthHttpHandler($innerHandler, $tracerProvider); + $request = new Request('POST', 'https://oauth2.googleapis.com/token'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unauthorized'); + + try { + $handler($request); + } finally { + $this->assertSame(401, $spanAttributes[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + $this->assertSame('401', $spanAttributes[SpanAttributes::ERROR_TYPE]); + } + } + + public function testAsyncSuccess() + { + $expectedResponse = new Response(200, [], '{"access_token": "async_token"}'); + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $tracerProvider->method('getTracer')->willReturn($tracer); + $tracer->method('spanBuilder')->willReturn($spanBuilder); + $spanBuilder->method('setSpanKind')->willReturnSelf(); + $spanBuilder->method('setAttribute')->willReturnSelf(); + $spanBuilder->method('startSpan')->willReturn($span); + $span->method('activate')->willReturn($scope); + + $spanAttributes = []; + $span->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$spanAttributes, $span) { + $spanAttributes[$key] = $value; + return $span; + }); + $span->expects($this->once()) + ->method('setStatus') + ->with(StatusCode::STATUS_OK); + $scope->expects($this->once()) + ->method('detach'); + $span->expects($this->once()) + ->method('end'); + + $innerHandler = function () use ($expectedResponse) { + return new FulfilledPromise($expectedResponse); + }; + + $handler = new AuthHttpHandler($innerHandler, $tracerProvider); + $request = new Request('POST', 'https://oauth2.googleapis.com/token'); + $promise = $handler($request); + + $actualResponse = $promise->wait(); + $this->assertSame($expectedResponse, $actualResponse); + $this->assertSame(200, $spanAttributes[SpanAttributes::HTTP_RESPONSE_STATUS_CODE]); + } + + public function testAsyncFailure() + { + $tracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $spanBuilder = $this->createMock(SpanBuilderInterface::class); + $span = $this->createMock(SpanInterface::class); + $scope = $this->createMock(ScopeInterface::class); + + $tracerProvider->method('getTracer')->willReturn($tracer); + $tracer->method('spanBuilder')->willReturn($spanBuilder); + $spanBuilder->method('setSpanKind')->willReturnSelf(); + $spanBuilder->method('setAttribute')->willReturnSelf(); + $spanBuilder->method('startSpan')->willReturn($span); + $span->method('activate')->willReturn($scope); + + $spanAttributes = []; + $span->method('setAttribute') + ->willReturnCallback(function ($key, $value) use (&$spanAttributes, $span) { + $spanAttributes[$key] = $value; + return $span; + }); + $span->expects($this->once()) + ->method('setStatus') + ->with(StatusCode::STATUS_ERROR, 'Async error'); + $scope->expects($this->once()) + ->method('detach'); + $span->expects($this->once()) + ->method('end'); + + $innerHandler = function () { + return new RejectedPromise(new RuntimeException('Async error')); + }; + + $handler = new AuthHttpHandler($innerHandler, $tracerProvider); + $request = new Request('POST', 'https://oauth2.googleapis.com/token'); + $promise = $handler($request); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Async error'); + + try { + $promise->wait(); + } finally { + $this->assertSame(RuntimeException::class, $spanAttributes[SpanAttributes::ERROR_TYPE]); + $this->assertSame('Async error', $spanAttributes[SpanAttributes::STATUS_MESSAGE]); + } + } +} diff --git a/Gax/tests/Unit/Telemetry/TelemetryConfigurationTest.php b/Gax/tests/Unit/Telemetry/TelemetryConfigurationTest.php new file mode 100644 index 000000000000..f29f175704e1 --- /dev/null +++ b/Gax/tests/Unit/Telemetry/TelemetryConfigurationTest.php @@ -0,0 +1,169 @@ +originalEnv = getenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + } + + protected function tearDown(): void + { + if ($this->originalEnv !== false) { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=' . $this->originalEnv); + } else { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + } + parent::tearDown(); + } + + public function testIsTracingEnabledDefault() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + $this->assertFalse(TelemetryConfiguration::isTracingEnabled()); + } + + public function testIsTracingEnabledTrue() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=true'); + $this->assertTrue(TelemetryConfiguration::isTracingEnabled()); + } + + public function testIsTracingEnabledFalse() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=false'); + $this->assertFalse(TelemetryConfiguration::isTracingEnabled()); + } + + public function testIsTracingEnabledWithExplicitProviderWhenUnset() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertTrue(TelemetryConfiguration::isTracingEnabled($mockProvider)); + } + + public function testIsTracingEnabledWithExplicitProviderWhenDisabled() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=false'); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertFalse(TelemetryConfiguration::isTracingEnabled($mockProvider)); + } + + public function testResolveTracerProviderDefault() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + $this->assertNull(TelemetryConfiguration::resolveTracerProvider()); + } + + public function testResolveTracerProviderExplicitWhenUnset() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED'); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertSame($mockProvider, TelemetryConfiguration::resolveTracerProvider($mockProvider)); + } + + /** + * @dataProvider disabledValuesProvider + */ + public function testResolveTracerProviderGlobalVeto($value) + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=' . $value); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertNull(TelemetryConfiguration::resolveTracerProvider($mockProvider)); + } + + public function disabledValuesProvider() + { + return [ + ['false'], + ['0'], + ['no'], + ['off'], + ]; + } + + /** + * @dataProvider enabledValuesProvider + */ + public function testResolveTracerProviderAutoDiscovery($value) + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED=' . $value); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertSame($mockProvider, TelemetryConfiguration::resolveTracerProvider($mockProvider)); + + $resolved = TelemetryConfiguration::resolveTracerProvider(); + if (class_exists(Globals::class)) { + $this->assertSame(Globals::tracerProvider(), $resolved); + } else { + $this->assertNull($resolved); + } + } + + public function enabledValuesProvider() + { + return [ + ['true'], + ['1'], + ['yes'], + ['on'], + ]; + } + + public function testResolveTracerProviderEmptyStringDoesNotVeto() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED='); + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertSame($mockProvider, TelemetryConfiguration::resolveTracerProvider($mockProvider)); + $this->assertNull(TelemetryConfiguration::resolveTracerProvider()); + } + + public function testIsTracingEnabledEmptyString() + { + putenv('GOOGLE_SDK_PHP_TRACING_ENABLED='); + $this->assertFalse(TelemetryConfiguration::isTracingEnabled()); + + $mockProvider = $this->createMock(TracerProviderInterface::class); + $this->assertTrue(TelemetryConfiguration::isTracingEnabled($mockProvider)); + } +} diff --git a/Gax/tests/Unit/Transport/GrpcFallbackTransportTest.php b/Gax/tests/Unit/Transport/GrpcFallbackTransportTest.php index 205f26ab5a7b..07f09422d6cd 100644 --- a/Gax/tests/Unit/Transport/GrpcFallbackTransportTest.php +++ b/Gax/tests/Unit/Transport/GrpcFallbackTransportTest.php @@ -35,6 +35,7 @@ use Exception; use Google\ApiCore\ApiException; use Google\ApiCore\Call; +use Google\ApiCore\Telemetry\SpanAttributes; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Testing\MockResponse; use Google\ApiCore\Transport\GrpcFallbackTransport; @@ -46,6 +47,12 @@ use GuzzleHttp\Promise\Create; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use InvalidArgumentException; +use OpenTelemetry\API\Trace\SpanBuilderInterface; +use OpenTelemetry\API\Trace\SpanInterface; +use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\API\Trace\TracerInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -238,4 +245,185 @@ public function testNonBinaryProtobufResponseException() ->startUnaryCall($this->call, []) ->wait(); } + + public function testStartUnaryCallWithTracing() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $marshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $marshalingSpan = $this->createMock(SpanInterface::class); + $unmarshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $unmarshalingSpan = $this->createMock(SpanInterface::class); + + $openTelemetryTracerProvider->expects($this->exactly(2)) + ->method('getTracer') + ->with('google-cloud-php', '1.0.0') + ->willReturn($tracer); + + $marshalingAttributes = []; + $marshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $marshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$marshalingAttributes, $marshalingSpanBuilder) { + $marshalingAttributes[$k] = $v; + return $marshalingSpanBuilder; + }); + $marshalingSpanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($marshalingSpan); + $marshalingSpan->expects($this->once())->method('end'); + + $unmarshalingAttributes = []; + $unmarshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $unmarshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$unmarshalingAttributes, $unmarshalingSpanBuilder) { + $unmarshalingAttributes[$k] = $v; + return $unmarshalingSpanBuilder; + }); + $unmarshalingSpanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($unmarshalingSpan); + $unmarshalingSpan->expects($this->once())->method('setStatus')->with(StatusCode::STATUS_OK); + $unmarshalingSpan->expects($this->once())->method('end'); + + $tracer->expects($this->exactly(2)) + ->method('spanBuilder') + ->willReturnCallback(function ($spanName) use ($marshalingSpanBuilder, $unmarshalingSpanBuilder) { + if ($spanName === 'RequestMarshaling') { + return $marshalingSpanBuilder; + } + if ($spanName === 'ResponseUnmarshaling') { + return $unmarshalingSpanBuilder; + } + throw new InvalidArgumentException("Unexpected span name: $spanName"); + }); + + $expectedResponse = (new MockResponse()) + ->setName('hello') + ->setNumber(15); + + $httpHandler = function (RequestInterface $request) use ($expectedResponse) { + return Create::promiseFor( + new Response( + 200, + [], + $expectedResponse->serializeToString() + ) + ); + }; + + $transport = (new GrpcFallbackTransport( + 'www.example.com', + $httpHandler + ))->setTelemetryOptions([ + 'openTelemetryTracerProvider' => $openTelemetryTracerProvider, + SpanAttributes::GCP_CLIENT_REPO => 'googleapis/google-cloud-php', + SpanAttributes::GCP_CLIENT_ARTIFACT => 'google-cloud-secretmanager', + SpanAttributes::GCP_CLIENT_SERVICE => 'secretmanager', + SpanAttributes::GCP_CLIENT_VERSION => '1.0.0', + ]); + + $response = $transport->startUnaryCall($this->call, [])->wait(); + $this->assertInstanceOf(MockResponse::class, $response); + + // Verify RequestMarshaling attributes + $this->assertEquals('googleapis/google-cloud-php', $marshalingAttributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertEquals('google-cloud-secretmanager', $marshalingAttributes[SpanAttributes::GCP_CLIENT_ARTIFACT]); + $this->assertEquals('secretmanager', $marshalingAttributes[SpanAttributes::GCP_CLIENT_SERVICE]); + $this->assertEquals('1.0.0', $marshalingAttributes[SpanAttributes::GCP_CLIENT_VERSION]); + $this->assertEquals('Testing123', $marshalingAttributes[SpanAttributes::RPC_METHOD]); + $this->assertEquals('http', $marshalingAttributes[SpanAttributes::RPC_SYSTEM]); + + // Verify ResponseUnmarshaling attributes + $this->assertEquals('googleapis/google-cloud-php', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertEquals('google-cloud-secretmanager', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_ARTIFACT]); + $this->assertEquals('secretmanager', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_SERVICE]); + $this->assertEquals('1.0.0', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_VERSION]); + $this->assertEquals('Testing123', $unmarshalingAttributes[SpanAttributes::RPC_METHOD]); + $this->assertEquals('http', $unmarshalingAttributes[SpanAttributes::RPC_SYSTEM]); + } + + public function testStartUnaryCallResponseUnmarshalingError() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $marshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $marshalingSpan = $this->createMock(SpanInterface::class); + $unmarshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $unmarshalingSpan = $this->createMock(SpanInterface::class); + + $openTelemetryTracerProvider->expects($this->exactly(2)) + ->method('getTracer') + ->willReturn($tracer); + + $marshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $marshalingSpanBuilder->method('setAttribute')->willReturnSelf(); + $marshalingSpanBuilder->method('startSpan')->willReturn($marshalingSpan); + + $unmarshalingSpanAttributes = []; + $unmarshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $unmarshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$unmarshalingSpanAttributes, $unmarshalingSpanBuilder) { + $unmarshalingSpanAttributes[$k] = $v; + return $unmarshalingSpanBuilder; + }); + $unmarshalingSpanBuilder->method('startSpan')->willReturn($unmarshalingSpan); + + $errorAttributes = []; + $unmarshalingSpan->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$errorAttributes, $unmarshalingSpan) { + $errorAttributes[$k] = $v; + return $unmarshalingSpan; + }); + $unmarshalingSpan->expects($this->once())->method('setStatus')->with(StatusCode::STATUS_ERROR); + $unmarshalingSpan->expects($this->once())->method('end'); + + $tracer->method('spanBuilder') + ->willReturnCallback(function ($spanName) use ($marshalingSpanBuilder, $unmarshalingSpanBuilder) { + return $spanName === 'RequestMarshaling' ? $marshalingSpanBuilder : $unmarshalingSpanBuilder; + }); + + $httpHandler = function (RequestInterface $request, array $options = []) { + return Create::promiseFor( + new Response( + 200, + [], + 'dummy' + ) + ); + }; + + $transport = (new GrpcFallbackTransport( + 'www.example.com', + $httpHandler + ))->setTelemetryOptions([ + 'openTelemetryTracerProvider' => $openTelemetryTracerProvider, + SpanAttributes::GCP_CLIENT_SERVICE => 'test-service', + SpanAttributes::GCP_CLIENT_VERSION => '1.0.0', + ]); + + $anonClass = get_class(new class extends MockResponse { + public function __construct() + { + } + + public function mergeFromString(...$args) + { + throw new Exception('Unmarshaling failed'); + } + }); + + $call = $this->createMock(Call::class); + $call->method('getMethod')->willReturn('Testing123'); + $call->method('getMessage')->willReturn(new MockRequest()); + $call->method('getDecodeType')->willReturn($anonClass); + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected exception during unmarshaling'); + } catch (Exception $e) { + $this->assertEquals(get_class($e), $errorAttributes[SpanAttributes::ERROR_TYPE]); + $this->assertEquals(get_class($e), $errorAttributes[SpanAttributes::EXCEPTION_TYPE]); + $this->assertEquals('Unmarshaling failed', $errorAttributes[SpanAttributes::STATUS_MESSAGE]); + } + } } diff --git a/Gax/tests/Unit/Transport/RestTransportTest.php b/Gax/tests/Unit/Transport/RestTransportTest.php index fb0de054d360..5553e2b95129 100644 --- a/Gax/tests/Unit/Transport/RestTransportTest.php +++ b/Gax/tests/Unit/Transport/RestTransportTest.php @@ -39,6 +39,7 @@ use Google\ApiCore\CredentialsWrapper; use Google\ApiCore\RequestBuilder; use Google\ApiCore\ResumableUpload\ResumableUploadTransportInterface; +use Google\ApiCore\Telemetry\SpanAttributes; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Testing\MockResponse; use Google\ApiCore\Tests\Unit\TestTrait; @@ -51,12 +52,19 @@ use Google\Type\DateTime; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use InvalidArgumentException; +use OpenTelemetry\API\Trace\SpanBuilderInterface; +use OpenTelemetry\API\Trace\SpanInterface; +use OpenTelemetry\API\Trace\SpanKind; +use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\API\Trace\TracerInterface; +use OpenTelemetry\API\Trace\TracerProviderInterface; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use TypeError; use UnexpectedValueException; @@ -681,4 +689,180 @@ public function testBuildRequestWithDefaultHeaders() $actualRequest = $transport->buildRequest($method, $message); $this->assertSame($expectedRequest, $actualRequest); } + + public function testStartUnaryCallWithTracing() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $marshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $marshalingSpan = $this->createMock(SpanInterface::class); + $unmarshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $unmarshalingSpan = $this->createMock(SpanInterface::class); + + $openTelemetryTracerProvider->expects($this->exactly(2)) + ->method('getTracer') + ->with('google-cloud-php', '1.0.0') + ->willReturn($tracer); + + $marshalingAttributes = []; + $marshalingSpanBuilder->method('setSpanKind') + ->with(SpanKind::KIND_INTERNAL) + ->willReturnSelf(); + $marshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$marshalingAttributes, $marshalingSpanBuilder) { + $marshalingAttributes[$k] = $v; + return $marshalingSpanBuilder; + }); + $marshalingSpanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($marshalingSpan); + $marshalingSpan->expects($this->once())->method('end'); + + $unmarshalingAttributes = []; + $unmarshalingSpanBuilder->method('setSpanKind') + ->with(SpanKind::KIND_INTERNAL) + ->willReturnSelf(); + $unmarshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$unmarshalingAttributes, $unmarshalingSpanBuilder) { + $unmarshalingAttributes[$k] = $v; + return $unmarshalingSpanBuilder; + }); + $unmarshalingSpanBuilder->expects($this->once()) + ->method('startSpan') + ->willReturn($unmarshalingSpan); + $unmarshalingSpan->expects($this->once())->method('setStatus')->with(StatusCode::STATUS_OK); + $unmarshalingSpan->expects($this->once())->method('end'); + + $tracer->expects($this->exactly(2)) + ->method('spanBuilder') + ->willReturnCallback(function ($spanName) use ($marshalingSpanBuilder, $unmarshalingSpanBuilder) { + if ($spanName === 'RequestMarshaling') { + return $marshalingSpanBuilder; + } + if ($spanName === 'ResponseUnmarshaling') { + return $unmarshalingSpanBuilder; + } + throw new InvalidArgumentException("Unexpected span name: $spanName"); + }); + + $httpHandler = function ($request, $options) { + return new FulfilledPromise( + new Response(200, [], '{}') + ); + }; + + $requestBuilder = $this->createMock(RequestBuilder::class); + $requestBuilder->method('build')->willReturn(new Request('POST', 'https://example.com')); + + $transport = (new RestTransport( + $requestBuilder, + $httpHandler + ))->setTelemetryOptions([ + 'openTelemetryTracerProvider' => $openTelemetryTracerProvider, + SpanAttributes::GCP_CLIENT_REPO => 'googleapis/google-cloud-php', + SpanAttributes::GCP_CLIENT_ARTIFACT => 'google-cloud-secretmanager', + SpanAttributes::GCP_CLIENT_SERVICE => 'secretmanager', + SpanAttributes::GCP_CLIENT_VERSION => '1.0.0', + ]); + + $call = $this->createMock(Call::class); + $call->method('getMethod')->willReturn('TestService/TestMethod'); + $call->method('getMessage')->willReturn(new MockRequest()); + $call->method('getDecodeType')->willReturn(MockResponse::class); + + $response = $transport->startUnaryCall($call, [])->wait(); + $this->assertInstanceOf(MockResponse::class, $response); + + // Verify RequestMarshaling attributes + $this->assertEquals('googleapis/google-cloud-php', $marshalingAttributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertEquals('google-cloud-secretmanager', $marshalingAttributes[SpanAttributes::GCP_CLIENT_ARTIFACT]); + $this->assertEquals('secretmanager', $marshalingAttributes[SpanAttributes::GCP_CLIENT_SERVICE]); + $this->assertEquals('1.0.0', $marshalingAttributes[SpanAttributes::GCP_CLIENT_VERSION]); + $this->assertEquals('TestService/TestMethod', $marshalingAttributes[SpanAttributes::RPC_METHOD]); + $this->assertEquals('http', $marshalingAttributes[SpanAttributes::RPC_SYSTEM]); + + // Verify ResponseUnmarshaling attributes + $this->assertEquals('googleapis/google-cloud-php', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_REPO]); + $this->assertEquals( + 'google-cloud-secretmanager', + $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_ARTIFACT] + ); + $this->assertEquals('secretmanager', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_SERVICE]); + $this->assertEquals('1.0.0', $unmarshalingAttributes[SpanAttributes::GCP_CLIENT_VERSION]); + $this->assertEquals('TestService/TestMethod', $unmarshalingAttributes[SpanAttributes::RPC_METHOD]); + $this->assertEquals('http', $unmarshalingAttributes[SpanAttributes::RPC_SYSTEM]); + } + + public function testStartUnaryCallResponseUnmarshalingError() + { + $openTelemetryTracerProvider = $this->createMock(TracerProviderInterface::class); + $tracer = $this->createMock(TracerInterface::class); + $marshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $marshalingSpan = $this->createMock(SpanInterface::class); + $unmarshalingSpanBuilder = $this->createMock(SpanBuilderInterface::class); + $unmarshalingSpan = $this->createMock(SpanInterface::class); + + $openTelemetryTracerProvider->expects($this->exactly(2)) + ->method('getTracer') + ->willReturn($tracer); + + $marshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $marshalingSpanBuilder->method('setAttribute')->willReturnSelf(); + $marshalingSpanBuilder->method('startSpan')->willReturn($marshalingSpan); + + $unmarshalingSpanAttributes = []; + $unmarshalingSpanBuilder->method('setSpanKind')->willReturnSelf(); + $unmarshalingSpanBuilder->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$unmarshalingSpanAttributes, $unmarshalingSpanBuilder) { + $unmarshalingSpanAttributes[$k] = $v; + return $unmarshalingSpanBuilder; + }); + $unmarshalingSpanBuilder->method('startSpan')->willReturn($unmarshalingSpan); + + $errorAttributes = []; + $unmarshalingSpan->method('setAttribute') + ->willReturnCallback(function ($k, $v) use (&$errorAttributes, $unmarshalingSpan) { + $errorAttributes[$k] = $v; + return $unmarshalingSpan; + }); + $unmarshalingSpan->expects($this->once())->method('setStatus')->with(StatusCode::STATUS_ERROR); + $unmarshalingSpan->expects($this->once())->method('end'); + + $tracer->method('spanBuilder') + ->willReturnCallback(function ($spanName) use ($marshalingSpanBuilder, $unmarshalingSpanBuilder) { + return $spanName === 'RequestMarshaling' ? $marshalingSpanBuilder : $unmarshalingSpanBuilder; + }); + + $httpHandler = function ($request, $options) { + return new FulfilledPromise( + new Response(200, [], 'invalid-json') + ); + }; + + $requestBuilder = $this->createMock(RequestBuilder::class); + $requestBuilder->method('build')->willReturn(new Request('POST', 'https://example.com')); + + $transport = (new RestTransport( + $requestBuilder, + $httpHandler + ))->setTelemetryOptions([ + 'openTelemetryTracerProvider' => $openTelemetryTracerProvider, + SpanAttributes::GCP_CLIENT_SERVICE => 'test-service', + SpanAttributes::GCP_CLIENT_VERSION => '1.0.0' + ]); + + $call = $this->createMock(Call::class); + $call->method('getMethod')->willReturn('TestService/TestMethod'); + $call->method('getMessage')->willReturn(new MockRequest()); + $call->method('getDecodeType')->willReturn(MockResponse::class); + + try { + $transport->startUnaryCall($call, [])->wait(); + $this->fail('Expected exception during unmarshaling'); + } catch (Exception $e) { + $this->assertEquals(get_class($e), $errorAttributes[SpanAttributes::ERROR_TYPE]); + $this->assertEquals(get_class($e), $errorAttributes[SpanAttributes::EXCEPTION_TYPE]); + $this->assertNotEmpty($errorAttributes[SpanAttributes::STATUS_MESSAGE]); + } + } } diff --git a/composer.json b/composer.json index 10d3a0f43f19..dedb9a762b47 100644 --- a/composer.json +++ b/composer.json @@ -300,7 +300,7 @@ "google/common-protos": "4.14.2", "google/developer-knowledge": "0.4.0", "google/developers-knowledge": "0.0.0", - "google/gax": "1.49.0", + "google/gax": "1.50.0", "google/geo-common-protos": "0.2.5", "google/grafeas": "1.9.1", "google/longrunning": "0.8.3",