From 7b16fecaf68322d9d44bd9ecbdcde44abb0c51ae Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 18 Aug 2026 22:53:34 +0000 Subject: [PATCH 1/6] feat(Spanner): modify the exporter to use a native OTLP exporter --- Spanner/composer.json | 4 +- .../Middleware/MetricsAttemptMiddleware.php | 28 +- .../Middleware/MetricsOperationMiddleware.php | 20 +- Spanner/src/OpenTelemetry/MetricsExporter.php | 311 +++++------------- Spanner/src/SpannerClient.php | 70 ++-- .../BuiltInMetricsAttemptMiddlewareTest.php | 32 +- .../BuiltInMetricsOperationMiddlewareTest.php | 12 +- .../BuiltInMetricsExporterTest.php | 99 ++---- composer.json | 4 +- 9 files changed, 175 insertions(+), 405 deletions(-) diff --git a/Spanner/composer.json b/Spanner/composer.json index f1829221006a..4f9db614ec40 100644 --- a/Spanner/composer.json +++ b/Spanner/composer.json @@ -8,8 +8,8 @@ "ext-grpc": "*", "google/cloud-core": "^1.73.0", "google/gax": "^1.41.0", - "google/cloud-monitoring": "^2.2", - "open-telemetry/sdk": "^1.13" + "open-telemetry/sdk": "^1.13", + "open-telemetry/exporter-otlp": "^1.4" }, "require-dev": { "phpunit/phpunit": "^9.6", diff --git a/Spanner/src/Middleware/MetricsAttemptMiddleware.php b/Spanner/src/Middleware/MetricsAttemptMiddleware.php index 14625cc152ac..7105f74cb38a 100644 --- a/Spanner/src/Middleware/MetricsAttemptMiddleware.php +++ b/Spanner/src/Middleware/MetricsAttemptMiddleware.php @@ -63,14 +63,10 @@ class MetricsAttemptMiddleware implements MiddlewareInterface /** @var callable */ private $nextHandler; - private string $projectId; private string $clientId; private string $clientName; - private string $location; private bool $directPathEnabled; - private const INSTANCE_CONFIG = 'unknown'; - private const BUCKET_BOUNDS = [ 0.0, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, @@ -86,57 +82,51 @@ class MetricsAttemptMiddleware implements MiddlewareInterface * @param callable $nextHandler * @param MeterInterface $meter * @param string $clientId - * @param string $projectId * @param string $clientName - * @param string $location */ public function __construct( callable $nextHandler, MeterInterface $meter, string $clientId, - string $projectId, - string $clientName, - string $location + string $clientName ) { $this->nextHandler = $nextHandler; $advisory = ['ExplicitBucketBoundaries' => self::BUCKET_BOUNDS]; $this->attemptLatencyHistogram = $meter->createHistogram( - 'attempt_latencies', + 'spanner.googleapis.com/internal/client/attempt_latencies', 'ms', 'The latency of an RPC attempt', $advisory ); $this->attemptCountCounter = $meter->createCounter( - 'attempt_count', + 'spanner.googleapis.com/internal/client/attempt_count', '1', 'The number of RPC attempts' ); $this->attemptGfeHistogram = $meter->createHistogram( - 'gfe_latencies', + 'spanner.googleapis.com/internal/client/gfe_latencies', 'ms', 'Latency between Google\'s network receiving an RPC and reading back the first byte of the response', $advisory ); $this->gfeConnectivityErrorCounter = $meter->createCounter( - 'gfe_connectivity_error_count', + 'spanner.googleapis.com/internal/client/gfe_connectivity_error_count', '1', 'Number of RPC attempts that failed to reach the GFE or returned no GFE headers' ); $this->attemptAfeHistogram = $meter->createHistogram( - 'afe_latencies', + 'spanner.googleapis.com/internal/client/afe_latencies', 'ms', 'Latency between Spanner Spanner AFE receiving and returning a response.', $advisory ); $this->afeConnectivityErrorCounter = $meter->createCounter( - 'afe_connectivity_error_count', + 'spanner.googleapis.com/internal/client/afe_connectivity_error_count', '1', 'Number of connectivity errors for Spanner AFE' ); $this->clientId = $clientId; - $this->projectId = $projectId; $this->clientName = 'spanner-php/' . $clientName; - $this->location = $location; $this->directPathEnabled = filter_var( getenv('GOOGLE_SPANNER_ENABLE_DIRECT_ACCESS'), FILTER_VALIDATE_BOOLEAN @@ -323,13 +313,9 @@ private function getMetricLabels(string $method, array $options, int $code, bool return [ 'method' => $methodName, 'status' => $codeName, - 'instance_id' => $instanceId, 'database' => $databaseId, - 'project_id' => $this->projectId, 'client_uid' => $this->clientId, 'client_name' => $this->clientName, - 'instance_config' => self::INSTANCE_CONFIG, - 'location' => $this->location, 'directpath_enabled' => $this->directPathEnabled ? 'true' : 'false', 'directpath_used' => $directPathUsed ? 'true' : 'false' ]; diff --git a/Spanner/src/Middleware/MetricsOperationMiddleware.php b/Spanner/src/Middleware/MetricsOperationMiddleware.php index 825862fca928..dd200b7cb0a3 100644 --- a/Spanner/src/Middleware/MetricsOperationMiddleware.php +++ b/Spanner/src/Middleware/MetricsOperationMiddleware.php @@ -56,14 +56,10 @@ class MetricsOperationMiddleware implements MiddlewareInterface /** @var callable */ private $nextHandler; - private string $projectId; private string $clientId; private string $clientName; - private string $location; private bool $directPathEnabled; - private const INSTANCE_CONFIG = 'unknown'; - private const BUCKET_BOUNDS = [ 0.0, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, @@ -79,34 +75,28 @@ class MetricsOperationMiddleware implements MiddlewareInterface * @param callable $nextHandler * @param MeterInterface $meter * @param string $clientId - * @param string $projectId - * @param string $location */ public function __construct( callable $nextHandler, MeterInterface $meter, string $clientId, - string $projectId, - string $clientName, - string $location + string $clientName ) { $this->nextHandler = $nextHandler; $advisory = ['ExplicitBucketBoundaries' => self::BUCKET_BOUNDS]; $this->operationLatencyHistogram = $meter->createHistogram( - 'operation_latencies', + 'spanner.googleapis.com/internal/client/operation_latencies', 'ms', 'The latency of an RPC operations', $advisory ); $this->operationCountCounter = $meter->createCounter( - 'operation_count', + 'spanner.googleapis.com/internal/client/operation_count', '1', 'The number of RPC operations' ); $this->clientId = $clientId; - $this->projectId = $projectId; $this->clientName = 'spanner-php/' . $clientName; - $this->location = $location; $this->directPathEnabled = filter_var( getenv('GOOGLE_SPANNER_ENABLE_DIRECT_ACCESS'), FILTER_VALIDATE_BOOLEAN @@ -222,13 +212,9 @@ private function recordOperation( $labels = [ 'method' => $methodName, 'status' => $codeName, - 'instance_id' => $instanceId, 'database' => $databaseId, - 'project_id' => $this->projectId, 'client_uid' => $this->clientId, 'client_name' => $this->clientName, - 'instance_config' => self::INSTANCE_CONFIG, - 'location' => $this->location, 'directpath_enabled' => $this->directPathEnabled ? 'true' : 'false', 'directpath_used' => $directPathUsed ? 'true' : 'false' ]; diff --git a/Spanner/src/OpenTelemetry/MetricsExporter.php b/Spanner/src/OpenTelemetry/MetricsExporter.php index 249adcb10e47..8157b5a7bb0a 100644 --- a/Spanner/src/OpenTelemetry/MetricsExporter.php +++ b/Spanner/src/OpenTelemetry/MetricsExporter.php @@ -32,291 +32,134 @@ namespace Google\Cloud\Spanner\OpenTelemetry; -use Google\Api\Distribution; -use Google\Api\Distribution\BucketOptions; -use Google\Api\Distribution\BucketOptions\Explicit; -use Google\Api\Metric; -use Google\Api\MetricDescriptor\MetricKind; -use Google\Api\MetricDescriptor\ValueType; -use Google\Api\MonitoredResource; -use Google\Cloud\Monitoring\V3\Client\MetricServiceClient; -use Google\Cloud\Monitoring\V3\CreateTimeSeriesRequest; -use Google\Cloud\Monitoring\V3\Point; -use Google\Cloud\Monitoring\V3\TimeInterval; -use Google\Cloud\Monitoring\V3\TimeSeries; -use Google\Cloud\Monitoring\V3\TypedValue; -use Google\Protobuf\Timestamp; +use Google\Auth\ApplicationDefaultCredentials; +use Google\Auth\Middleware\AuthTokenMiddleware; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\HandlerStack; +use OpenTelemetry\Contrib\Otlp\MetricExporter as OtlpMetricExporter; +use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; use OpenTelemetry\SDK\Metrics\AggregationTemporalitySelectorInterface; -use OpenTelemetry\SDK\Metrics\Data\DataInterface; -use OpenTelemetry\SDK\Metrics\Data\Histogram; -use OpenTelemetry\SDK\Metrics\Data\HistogramDataPoint; use OpenTelemetry\SDK\Metrics\Data\Metric as OTelMetric; -use OpenTelemetry\SDK\Metrics\Data\NumberDataPoint; -use OpenTelemetry\SDK\Metrics\Data\Sum; use OpenTelemetry\SDK\Metrics\Data\Temporality; use OpenTelemetry\SDK\Metrics\MetricMetadataInterface; use OpenTelemetry\SDK\Metrics\PushMetricExporterInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Throwable; /** - * MetricsExporter exports Spanner client metrics to Google Cloud Monitoring - * using the internal service endpoint. + * MetricsExporter encapsulates the standard OpenTelemetry OTLP MetricExporter + * targeting Google Cloud Telemetry endpoint. + * + * @internal */ class MetricsExporter implements PushMetricExporterInterface, AggregationTemporalitySelectorInterface { - private const SPANNER_RESOURCE_TYPE = 'spanner_instance_client'; - private const NATIVE_METRICS_PREFIX = 'spanner.googleapis.com/internal/client/'; - private const SEND_BATCH_SIZE = 200; + private const DEFAULT_ENDPOINT = 'https://telemetry.googleapis.com/v1/metrics'; + private const MONITORING_WRITE_SCOPE = 'https://www.googleapis.com/auth/monitoring.write'; + private const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; - /** - * Labels that belong to the MonitoredResource rather than the Metric. - */ - private static array $MONITORED_RES_LABELS = [ - 'project_id' => true, - 'instance_id' => true, - 'instance_config' => true, - 'location' => true, - 'client_hash' => true, - ]; - - private MetricServiceClient $client; private string $projectId; - private string $clientHash; - private int $timeoutMillis; + private PushMetricExporterInterface $otlpExporter; /** - * @param MetricServiceClient $client The monitoring client. * @param string $projectId The GCP project ID metrics will be written to. - * @param string $clientUid The unique client identifier. * @param int $timeoutMillis The timeout defined for the metrics client during export. + * @param array $options Optional configuration parameters. + * @param PushMetricExporterInterface|null $otlpExporter Optional inner exporter for testing. */ - public function __construct(MetricServiceClient $client, string $projectId, string $clientUid, int $timeoutMillis) - { - $this->client = $client; + public function __construct( + string $projectId, + int $timeoutMillis = 5000, + array $options = [], + ?PushMetricExporterInterface $otlpExporter = null + ) { $this->projectId = $projectId; - $this->clientHash = $this->generateClientHash($clientUid); - $this->timeoutMillis = $timeoutMillis; - } - - /** - * Exports a batch of OTel metrics to Cloud Monitoring. - * - * @param iterable $batch - * @return bool - */ - public function export(iterable $batch): bool - { - $timeSeriesList = []; - foreach ($batch as $otelMetric) { - $timeSeriesList = array_merge($timeSeriesList, $this->mapMetric($otelMetric)); - } - if (empty($timeSeriesList)) { - return true; + if ($otlpExporter !== null) { + $this->otlpExporter = $otlpExporter; + return; } - $projectName = MetricServiceClient::projectName($this->projectId); - $chunks = array_chunk($timeSeriesList, self::SEND_BATCH_SIZE); + $endpoint = $options['endpoint'] ?? self::DEFAULT_ENDPOINT; - foreach ($chunks as $chunk) { - $request = new CreateTimeSeriesRequest(); - $request->setName($projectName); - $request->setTimeSeries($chunk); + $handlerStack = HandlerStack::create(); + try { + $fetcher = ApplicationDefaultCredentials::getCredentials([ + self::MONITORING_WRITE_SCOPE, + self::CLOUD_PLATFORM_SCOPE + ]); - try { - $this->client->createServiceTimeSeries($request, [ - 'timeoutMillis' => $this->timeoutMillis - ]); - } catch (\Exception $e) { - // Fail silently during shutdown to avoid user-visible errors. - } + $handlerStack->push(new AuthTokenMiddleware($fetcher)); + } catch (Throwable $e) { + // Proceed without auth middleware if ADC is unavailable } - return true; - } + $guzzleClient = $options['guzzleClient'] ?? new GuzzleClient([ + 'handler' => $handlerStack, + 'auth' => 'google_auth', + 'timeout' => $timeoutMillis / 1000, + ]); - /** - * Implementation of the forcePush method for PushMetricExporter interface. - * - * @return true - */ - public function forceFlush(): bool - { - return true; - } + $transport = (new PsrTransportFactory($guzzleClient))->create( + $endpoint, + 'application/x-protobuf' + ); - /** - * Implementation of the shutdown method for PushMetricExporterInterface. - * - * @return true - */ - public function shutdown(): bool - { - $this->client->close(); - return true; - } - - /** - * Returns the aggregation temporality for the given metric. - * - * @param MetricMetadataInterface $metadata - * @return string - */ - public function temporality(MetricMetadataInterface $metadata): string - { - return Temporality::CUMULATIVE; + $this->otlpExporter = new OtlpMetricExporter($transport, Temporality::CUMULATIVE); } /** - * Maps an OTel Metric object to one or more GCM TimeSeries objects. + * Exports a batch of OTel metrics using the inner OTLP exporter. * - * @param OTelMetric $otelMetric + * @param iterable $batch + * @return bool */ - private function mapMetric(OTelMetric $otelMetric): array + public function export(iterable $batch): bool { - $timeSeriesList = []; - $metricType = $this->formatMetricName($otelMetric->name); - - $data = $otelMetric->data; - if ($data instanceof Sum || $data instanceof Histogram) { - foreach ($data->dataPoints as $point) { - $timeSeriesList[] = $this->createTimeSeries($metricType, $point, $otelMetric->unit, $data); - } + try { + return $this->otlpExporter->export($batch); + } catch (Throwable $e) { + return false; } - - return $timeSeriesList; } /** - * Creates a single GCM TimeSeries from an OTel DataPoint. + * Implementation of forceFlush method for PushMetricExporterInterface. * - * @param string $metricType - * @param NumberDataPoint|HistogramDataPoint $otelPoint - * @param string|null $unit - * @param DataInterface $otelData - * @return TimeSeries - */ - private function createTimeSeries( - string $metricType, - NumberDataPoint|HistogramDataPoint $otelPoint, - ?string $unit, - DataInterface $otelData - ): TimeSeries { - $ts = new TimeSeries(); - $unit = $unit ?? '1'; - - $metricLabels = []; - $resourceLabels = [ - 'client_hash' => $this->clientHash, - ]; - - // Distribute attributes between Resource and Metric labels - foreach ($otelPoint->attributes as $key => $value) { - $labelKey = str_replace('.', '_', $key); - if (isset(self::$MONITORED_RES_LABELS[$labelKey])) { - $resourceLabels[$labelKey] = (string) $value; - } else { - $metricLabels[$labelKey] = (string) $value; - } - } - - $metric = new Metric(); - $metric->setType($metricType); - $metric->setLabels($metricLabels); - $ts->setMetric($metric); - - $resource = new MonitoredResource(); - $resource->setType(self::SPANNER_RESOURCE_TYPE); - $resource->setLabels($resourceLabels); - $ts->setResource($resource); - - $ts->setUnit($unit); - - $point = new Point(); - $interval = new TimeInterval(); - - // Convert nanoseconds to Protobuf Timestamp - $interval->setStartTime($this->toTimestamp($otelPoint->startTimestamp)); - $interval->setEndTime($this->toTimestamp($otelPoint->timestamp)); - $point->setInterval($interval); - - $value = new TypedValue(); - if ($otelData instanceof Sum) { - $ts->setMetricKind($otelData->monotonic ? MetricKind::CUMULATIVE : MetricKind::GAUGE); - if (is_int($otelPoint->value)) { - $value->setInt64Value($otelPoint->value); - $ts->setValueType(ValueType::INT64); - } else { - $value->setDoubleValue((float) $otelPoint->value); - $ts->setValueType(ValueType::DOUBLE); - } - } elseif ($otelData instanceof Histogram) { - $ts->setMetricKind(MetricKind::CUMULATIVE); - $ts->setValueType(ValueType::DISTRIBUTION); - - $dist = new Distribution(); - $dist->setCount($otelPoint->count); - if ($otelPoint->count > 0) { - $dist->setMean($otelPoint->sum / $otelPoint->count); - } - $dist->setBucketCounts($otelPoint->bucketCounts); - - $bucketOptions = new BucketOptions(); - $explicit = new Explicit(); - $explicit->setBounds($otelPoint->explicitBounds); - $bucketOptions->setExplicitBuckets($explicit); - $dist->setBucketOptions($bucketOptions); - - $value->setDistributionValue($dist); - } - - $point->setValue($value); - $ts->setPoints([$point]); - - return $ts; - } - - /** - * Formats the metric name for Cloud Monitoring. - * Built-in metrics MUST use the specific internal namespace. - * - * @param string $name The OTel instrument name. - * @return string The fully qualified GCM metric type. + * @return bool */ - private function formatMetricName(string $name): string + public function forceFlush(): bool { - return self::NATIVE_METRICS_PREFIX . $name; + try { + return $this->otlpExporter->forceFlush(); + } catch (Throwable $e) { + return false; + } } /** - * Converts nanoseconds to a php Timestamp + * Implementation of shutdown method for PushMetricExporterInterface. * - * @param int $nanos - * @return Timestamp + * @return bool */ - private function toTimestamp(int $nanos): Timestamp + public function shutdown(): bool { - $timestamp = new Timestamp(); - $timestamp->setSeconds((int) ($nanos / 1_000_000_000)); - $timestamp->setNanos((int) ($nanos % 1_000_000_000)); - return $timestamp; + try { + return $this->otlpExporter->shutdown(); + } catch (Throwable $e) { + return false; + } } /** - * Returns a hash of the client UUID for the metrics + * Returns the aggregation temporality for the given metric. * - * @param string $clientUid - * @return string + * @param MetricMetadataInterface $metric + * @return Temporality|string|null */ - private function generateClientHash(string $clientUid): string + public function temporality(MetricMetadataInterface $metric): Temporality|string|null { - if ($clientUid === '') { - return '000000'; - } - - $hashHex = hash('fnv164', $clientUid); - $firstFour = substr($hashHex, 0, 4); - $intVal = hexdec($firstFour); - $tenBits = $intVal >> 6; - return sprintf('%06x', $tenBits); + return Temporality::CUMULATIVE; } } diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index ed289bed9976..e0b4317140d9 100644 --- a/Spanner/src/SpannerClient.php +++ b/Spanner/src/SpannerClient.php @@ -33,7 +33,6 @@ use Google\Cloud\Core\LongRunning\LongRunningClientConnection; use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Core\OptionsValidator; -use Google\Cloud\Monitoring\V3\Client\MetricServiceClient; use Google\Cloud\Spanner\Admin\Database\V1\Client\DatabaseAdminClient; use Google\Cloud\Spanner\Admin\Instance\V1\Client\InstanceAdminClient; use Google\Cloud\Spanner\Admin\Instance\V1\InstanceConfig; @@ -54,9 +53,11 @@ use Google\Protobuf\Duration; use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\MeterProviderInterface; +use OpenTelemetry\SDK\Common\Attribute\Attributes; use OpenTelemetry\SDK\Common\Util\ShutdownHandler; use OpenTelemetry\SDK\Metrics\MeterProvider; use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader; +use OpenTelemetry\SDK\Resource\ResourceInfo; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\StreamInterface; use Ramsey\Uuid\Uuid as RUUID; @@ -1055,42 +1056,28 @@ private function configureKeepAlive(array $config): array private function configureMetrics(array $options): void { - $metricsClient = $this->pluck('metricServiceClient', $options, false); - $timeoutMillis = $this->pluck('metricsTimeoutMillis', $options, false) ?? 100; + $timeoutMillis = $this->pluck('metricsTimeoutMillis', $options, false) ?? 5000; if (!$this->pluck('enableBuiltInMetrics', $options, false)) { return; } - if (!$metricsClient) { - $metricsOptions = [ - 'projectId' => $this->projectId, - 'keyFile' => $options['keyFile'] ?? null, - 'keyFilePath' => $options['keyFilePath'] ?? null, - 'credentials' => $options['credentials'] ?? null, - 'credentialsConfig' => $options['credentialsConfig'] ?? null, - 'universeDomain' => $options['universeDomain'] ?? null, - 'transport' => $options['transport'] ?? null, - 'transportConfig' => $options['transportConfig'] ?? null - ]; - - try { - $metricsClient = new MetricServiceClient(array_filter($metricsOptions)); - } catch (ValidationException $e) { - // If we cannot instantiate the metrics client, we should not stop the execution - return; - } - } - - if (!$metricsClient instanceof MetricServiceClient) { - throw new ValidationException('The "metricServiceClient" option must be a MetricServiceClient instance.'); - } - $location = $this->getLocation(); $metricsClientId = RUUID::uuid4()->toString() . '-' . getmypid(); - $exporter = new MetricsExporter($metricsClient, $this->projectId, $metricsClientId, $timeoutMillis); + $clientHash = $this->generateClientHash($metricsClientId); + + $resource = ResourceInfo::create(Attributes::create([ + 'gcp.resource_type' => 'spanner_instance_client', + 'gcp.project_id' => $this->projectId, + 'project_id' => $this->projectId, + 'client_hash' => $clientHash, + 'location' => $location !== 'global' ? $location : 'us-central1', + ])); + + $exporter = new MetricsExporter($this->projectId, $timeoutMillis, $options); $reader = new ExportingReader($exporter); $this->meterProvider = MeterProvider::builder() + ->setResource($resource) ->addReader($reader) ->build(); @@ -1102,9 +1089,7 @@ private function configureMetrics(array $options): void $handler, $this->meter, $metricsClientId, - $this->projectId, - $this->clientVersion(), - $location + $this->clientVersion() ); }; @@ -1113,9 +1098,7 @@ private function configureMetrics(array $options): void $handler, $this->meter, $metricsClientId, - $this->projectId, - $this->clientVersion(), - $location + $this->clientVersion() ); }; @@ -1172,4 +1155,23 @@ private function getLocation(): string return $location; } + + /** + * Returns a hash of the client UUID for the metrics. + * + * @param string $clientUid + * @return string + */ + private function generateClientHash(string $clientUid): string + { + if ($clientUid === '') { + return '000000'; + } + + $hashHex = hash('fnv1a64', $clientUid); + $firstFour = substr($hashHex, 0, 4); + $intVal = hexdec($firstFour); + $tenBits = $intVal >> 6; + return sprintf('%06x', $tenBits); + } } diff --git a/Spanner/tests/Unit/Middleware/BuiltInMetricsAttemptMiddlewareTest.php b/Spanner/tests/Unit/Middleware/BuiltInMetricsAttemptMiddlewareTest.php index 8211ceb50b5c..738753313c99 100644 --- a/Spanner/tests/Unit/Middleware/BuiltInMetricsAttemptMiddlewareTest.php +++ b/Spanner/tests/Unit/Middleware/BuiltInMetricsAttemptMiddlewareTest.php @@ -58,40 +58,40 @@ public function setUp(): void $this->meter = $this->prophesize(MeterInterface::class); $this->meter->createHistogram( - 'attempt_latencies', + 'spanner.googleapis.com/internal/client/attempt_latencies', 'ms', Argument::any(), Argument::any() )->willReturn($this->attemptHistogram->reveal()); $this->meter->createCounter( - 'attempt_count', + 'spanner.googleapis.com/internal/client/attempt_count', '1', Argument::any() )->willReturn($this->attemptCounter->reveal()); $this->meter->createHistogram( - 'gfe_latencies', + 'spanner.googleapis.com/internal/client/gfe_latencies', 'ms', Argument::any(), Argument::any() )->willReturn($this->gfeHistogram->reveal()); $this->meter->createCounter( - 'gfe_connectivity_error_count', + 'spanner.googleapis.com/internal/client/gfe_connectivity_error_count', '1', Argument::any() )->willReturn($this->gfeErrorCounter->reveal()); $this->meter->createHistogram( - 'afe_latencies', + 'spanner.googleapis.com/internal/client/afe_latencies', 'ms', Argument::any(), Argument::any() )->willReturn($this->afeHistogram->reveal()); $this->meter->createCounter( - 'afe_connectivity_error_count', + 'spanner.googleapis.com/internal/client/afe_connectivity_error_count', '1', Argument::any() )->willReturn($this->afeErrorCounter->reveal()); @@ -118,9 +118,7 @@ public function testRecordsAttemptMetrics() $this->nextHandler, $this->meter->reveal(), $clientId, - $projectId, - $version, - $location + $version ); $call = $this->prophesize(Call::class); @@ -137,13 +135,9 @@ public function testRecordsAttemptMetrics() $expectedLabels = [ 'method' => 'Commit', 'status' => 'OK', - 'instance_id' => 'i', 'database' => 'd', - 'project_id' => $projectId, 'client_uid' => $clientId, 'client_name' => $expectedClientName, - 'instance_config' => 'unknown', - 'location' => $location, 'directpath_enabled' => 'false', 'directpath_used' => 'true' ]; @@ -174,9 +168,7 @@ public function testRecordsGfeMetricsOnStreamingResponse() $this->nextHandler, $this->meter->reveal(), 'client', - 'project', - 'name', - 'global' + 'name' ); $call = $this->prophesize(Call::class); @@ -209,9 +201,7 @@ public function testRecordsGfeErrorOnMissingHeader() $this->nextHandler, $this->meter->reveal(), 'client', - 'project', - 'name', - 'global' + 'name' ); $call = $this->prophesize(Call::class); @@ -239,9 +229,7 @@ public function testRecordsMetricsOnError() $this->nextHandler, $this->meter->reveal(), 'client', - 'project', - 'name', - 'global' + 'name' ); $call = $this->prophesize(Call::class); diff --git a/Spanner/tests/Unit/Middleware/BuiltInMetricsOperationMiddlewareTest.php b/Spanner/tests/Unit/Middleware/BuiltInMetricsOperationMiddlewareTest.php index ff480a8d99b3..41888d7e7c14 100644 --- a/Spanner/tests/Unit/Middleware/BuiltInMetricsOperationMiddlewareTest.php +++ b/Spanner/tests/Unit/Middleware/BuiltInMetricsOperationMiddlewareTest.php @@ -46,14 +46,14 @@ public function setUp(): void $this->meter = $this->prophesize(MeterInterface::class); $this->meter->createHistogram( - 'operation_latencies', + 'spanner.googleapis.com/internal/client/operation_latencies', 'ms', Argument::any(), Argument::any() )->willReturn($this->histogram->reveal()); $this->meter->createCounter( - 'operation_count', + 'spanner.googleapis.com/internal/client/operation_count', '1', Argument::any() )->willReturn($this->counter->reveal()); @@ -75,9 +75,7 @@ public function testRecordsOperationMetrics() $this->nextHandler, $this->meter->reveal(), $clientId, - $projectId, - $version, - $location + $version ); $call = $this->prophesize(Call::class); @@ -93,13 +91,9 @@ public function testRecordsOperationMetrics() $expectedLabels = [ 'method' => 'ExecuteSql', 'status' => 'OK', - 'instance_id' => 'i', 'database' => 'd', - 'project_id' => $projectId, 'client_uid' => $clientId, 'client_name' => $expectedClientName, - 'instance_config' => 'unknown', - 'location' => $location, 'directpath_enabled' => 'false', 'directpath_used' => 'false' ]; diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index ed333a157564..f06408628698 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -17,8 +17,6 @@ namespace Google\Cloud\Spanner\Tests\Unit\OpenTelemetry; -use Google\Cloud\Monitoring\V3\Client\MetricServiceClient; -use Google\Cloud\Monitoring\V3\CreateTimeSeriesRequest; use Google\Cloud\Spanner\OpenTelemetry\MetricsExporter; use OpenTelemetry\SDK\Common\Attribute\Attributes; use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScope; @@ -27,6 +25,7 @@ use OpenTelemetry\SDK\Metrics\Data\Sum; use OpenTelemetry\SDK\Metrics\Data\Temporality; use OpenTelemetry\SDK\Resource\ResourceInfo; +use OpenTelemetry\SDK\Metrics\PushMetricExporterInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -48,35 +47,38 @@ class BuiltInMetricsExporterTest extends TestCase */ public function testGenerateClientHash($clientUid, $expected) { - $client = $this->prophesize(MetricServiceClient::class); - $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, self::DEFAULT_TIMEOUT); - - $reflection = new ReflectionClass(MetricsExporter::class); + $reflection = new ReflectionClass(\Google\Cloud\Spanner\SpannerClient::class); $method = $reflection->getMethod('generateClientHash'); $method->setAccessible(true); - $result = $method->invoke($exporter, $clientUid); + $client = new ReflectionClass(\Google\Cloud\Spanner\SpannerClient::class); + $instance = $client->newInstanceWithoutConstructor(); + + $result = $method->invoke($instance, $clientUid); $this->assertEquals($expected, $result); } public function hashDataProvider() { return [ - ['exampleUID', '00006b'], + ['exampleUID', '000194'], ['', '000000'], - ['!@#$%^&*()', '000389'], - ['aVeryLongUniqueIdentifierThatExceedsNormalLength', '000125'], - ['1234567890', '00003e'], + ['!@#$%^&*()', '000129'], + ['aVeryLongUniqueIdentifierThatExceedsNormalLength', '00008e'], + ['1234567890', '00018d'], ]; } public function testExport() { - $client = $this->prophesize(MetricServiceClient::class); - $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, 100); + $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); + $exporter = new MetricsExporter(self::PROJECT_ID, 100, [], $mockOtlpExporter->reveal()); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); - $resource = ResourceInfo::create(Attributes::create(['service.name' => 'spanner'])); + $resource = ResourceInfo::create(Attributes::create([ + 'gcp.resource_type' => 'spanner_instance_client', + 'client_hash' => '000212' + ])); $attributes = Attributes::create([ 'method' => 'ExecuteSql', @@ -93,70 +95,39 @@ public function testExport() ); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric($scope, $resource, 'attempt_count', '1', 'desc', $sum); - - $client->createServiceTimeSeries(Argument::that(function ($request) { - if (!$request instanceof CreateTimeSeriesRequest) { - return false; - } - - $projectName = MetricServiceClient::projectName(self::PROJECT_ID); - if ($request->getName() !== $projectName) { - return false; - } - - $timeSeries = $request->getTimeSeries()[0]; - - // Verify Metric Type - $expectedMetric = 'spanner.googleapis.com/internal/client/attempt_count'; - if ($timeSeries->getMetric()->getType() !== $expectedMetric) { - return false; - } - - // Verify Labels - $labels = $timeSeries->getMetric()->getLabels(); - if ($labels['method'] !== 'ExecuteSql' || - $labels['status'] !== 'OK' || - $labels['database'] !== 'my-db') { - return false; - } - - // Verify Resource - $resLabels = $timeSeries->getResource()->getLabels(); - if ($resLabels['instance_id'] !== 'my-instance') { - return false; - } - - // Verify Client Hash - if ($resLabels['client_hash'] !== '000369') { - return false; - } - - return true; - }), Argument::withEntry('timeoutMillis', 100))->shouldBeCalled(); + $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); + + $mockOtlpExporter->export(Argument::type('iterable'))->shouldBeCalled()->willReturn(true); $this->assertTrue($exporter->export([$metric])); } public function testExportCustomTimeout() { - $client = $this->prophesize(MetricServiceClient::class); + $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); $timeout = 500; - $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, $timeout); + $exporter = new MetricsExporter(self::PROJECT_ID, $timeout, [], $mockOtlpExporter->reveal()); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); - $resource = ResourceInfo::create(Attributes::create(['service.name' => 'spanner'])); + $resource = ResourceInfo::create(Attributes::create([])); $attributes = Attributes::create([]); $point = new NumberDataPoint(1, $attributes, 1711368000000000000, 1711368060000000000); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric($scope, $resource, 'attempt_count', '1', 'desc', $sum); + $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); - $client->createServiceTimeSeries( - Argument::type(CreateTimeSeriesRequest::class), - Argument::withEntry('timeoutMillis', $timeout) - )->shouldBeCalled(); + $mockOtlpExporter->export(Argument::any())->shouldBeCalled()->willReturn(true); + + $this->assertTrue($exporter->export([$metric])); + } + + public function testConstructWithCustomMetricsCredentials() + { + $mockCredentials = $this->prophesize(\Google\Auth\FetchAuthTokenInterface::class); + $exporter = new MetricsExporter(self::PROJECT_ID, 5000, [ + 'metricsCredentials' => $mockCredentials->reveal() + ]); - $exporter->export([$metric]); + $this->assertInstanceOf(MetricsExporter::class, $exporter); } } diff --git a/composer.json b/composer.json index bc6a77c580b8..3da8ef869d11 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,8 @@ "google/protobuf": "^4.31||^5.34", "google/grpc-gcp": "^0.4", "ramsey/uuid": "^4.0", - "open-telemetry/sdk": "^1.13" + "open-telemetry/sdk": "^1.13", + "open-telemetry/exporter-otlp": "^1.4" }, "require-dev": { "phpunit/phpunit": "^9.6", @@ -211,7 +212,6 @@ "google/cloud-memorystore": "0.13.0", "google/cloud-migrationcenter": "1.3.0", "google/cloud-modelarmor": "0.8.0", - "google/cloud-monitoring": "2.3.0", "google/cloud-netapp": "1.13.0", "google/cloud-network-connectivity": "2.6.0", "google/cloud-network-management": "2.11.0", From d7973cff38b3e31fb8e19cda0ddbf143b7218c9c Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 19 Aug 2026 02:07:44 +0000 Subject: [PATCH 2/6] add support for configurable credentials for the OTLP metrics --- Spanner/src/OpenTelemetry/MetricsExporter.php | 38 +++++++------ Spanner/src/SpannerClient.php | 39 ++++++++++++-- .../BuiltInMetricsExporterTest.php | 53 ++++++++++++++++--- 3 files changed, 103 insertions(+), 27 deletions(-) diff --git a/Spanner/src/OpenTelemetry/MetricsExporter.php b/Spanner/src/OpenTelemetry/MetricsExporter.php index 8157b5a7bb0a..45776c964cfb 100644 --- a/Spanner/src/OpenTelemetry/MetricsExporter.php +++ b/Spanner/src/OpenTelemetry/MetricsExporter.php @@ -32,10 +32,12 @@ namespace Google\Cloud\Spanner\OpenTelemetry; +use Google\ApiCore\CredentialsWrapper; use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Middleware\AuthTokenMiddleware; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Request; use OpenTelemetry\Contrib\Otlp\MetricExporter as OtlpMetricExporter; use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; use OpenTelemetry\SDK\Metrics\AggregationTemporalitySelectorInterface; @@ -56,19 +58,21 @@ class MetricsExporter implements PushMetricExporterInterface, AggregationTemporalitySelectorInterface { private const DEFAULT_ENDPOINT = 'https://telemetry.googleapis.com/v1/metrics'; - private const MONITORING_WRITE_SCOPE = 'https://www.googleapis.com/auth/monitoring.write'; - private const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; + public const MONITORING_WRITE_SCOPE = 'https://www.googleapis.com/auth/monitoring.write'; + public const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; private string $projectId; private PushMetricExporterInterface $otlpExporter; /** + * @param CredentialsWrapper $metricsCredentials The credentials wrapper for metric export. * @param string $projectId The GCP project ID metrics will be written to. * @param int $timeoutMillis The timeout defined for the metrics client during export. * @param array $options Optional configuration parameters. * @param PushMetricExporterInterface|null $otlpExporter Optional inner exporter for testing. */ public function __construct( + CredentialsWrapper $metricsCredentials, string $projectId, int $timeoutMillis = 5000, array $options = [], @@ -81,19 +85,21 @@ public function __construct( return; } - $endpoint = $options['endpoint'] ?? self::DEFAULT_ENDPOINT; - - $handlerStack = HandlerStack::create(); - try { - $fetcher = ApplicationDefaultCredentials::getCredentials([ - self::MONITORING_WRITE_SCOPE, - self::CLOUD_PLATFORM_SCOPE - ]); - - $handlerStack->push(new AuthTokenMiddleware($fetcher)); - } catch (Throwable $e) { - // Proceed without auth middleware if ADC is unavailable - } + $authCallback = $metricsCredentials->getAuthorizationHeaderCallback(); + $quotaProject = $metricsCredentials->getQuotaProject(); + $handlerStack = $options['handlerStack'] ?? HandlerStack::create(); + $handlerStack->push(function (callable $handler) use ($authCallback, $quotaProject) { + return function (RequestInterface $request, array $options) use ($authCallback, $handler, $quotaProject) { + $headers = $authCallback ? $authCallback() : []; + foreach ($headers as $name => $values) { + $request = $request->withHeader($name, $values); + } + if ($quotaProject) { + $request = $request->withHeader('x-goog-user-project', $quotaProject); + } + return $handler($request, $options); + }; + }); $guzzleClient = $options['guzzleClient'] ?? new GuzzleClient([ 'handler' => $handlerStack, @@ -102,7 +108,7 @@ public function __construct( ]); $transport = (new PsrTransportFactory($guzzleClient))->create( - $endpoint, + self::DEFAULT_ENDPOINT, 'application/x-protobuf' ); diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index e0b4317140d9..8ab25395d269 100644 --- a/Spanner/src/SpannerClient.php +++ b/Spanner/src/SpannerClient.php @@ -19,10 +19,12 @@ use Exception; use Google\ApiCore\ClientOptionsTrait; +use Google\ApiCore\CredentialsWrapper; use Google\ApiCore\Middleware\MiddlewareInterface; use Google\ApiCore\Options\CallOptions; use Google\ApiCore\ValidationException; use Google\Auth\Credentials\GCECredentials; +use Google\Auth\GetUniverseDomainInterface; use Google\Cloud\Core\ApiHelperTrait; use Google\Cloud\Core\Compute\Metadata; use Google\Cloud\Core\DetectProjectIdTrait; @@ -205,10 +207,10 @@ class SpannerClient * @type CacheItemPoolInterface $cacheItemPool * @type bool $enableBuiltInMetrics If true, built-in metrics collection will be enabled. * **Defaults to** false. - * @type int $metricsTimeoutMillis The timeout in milliseconds for the internal - * `MetricServiceClient` used to export metrics. **Defaults to** 100. - * @type MetricServiceClient $metricServiceClient An explicit instance of - * `MetricServiceClient` to use for exporting metrics. + * @type int $metricsTimeoutMillis The timeout in milliseconds for exporting metrics. + * **Defaults to** 5000. + * @type string|array|FetchAuthTokenInterface|CredentialsWrapper $metricsCredentials + * Optional dedicated credentials to use for exporting built-in metrics. * } * @throws GoogleException If the gRPC extension is not enabled. */ @@ -253,6 +255,10 @@ public function __construct(array $options = []) $options['credentialsConfig']['scopes'] = $scopes; } + $metricsCredentials = $options['metricsCredentials'] + ?? $options['credentials'] + ?? null; + if ($emulatorHost) { $emulatorConfig = $this->emulatorGapicConfig($emulatorHost); $options = array_merge( @@ -300,6 +306,7 @@ public function __construct(array $options = []) $this->instanceAdminClient->addMiddleware($middleware); $this->databaseAdminClient->addMiddleware($middleware); + $options['metricsCredentials'] = $metricsCredentials; $this->configureMetrics($options); $this->projectName = InstanceAdminClient::projectName($this->projectId); @@ -1074,7 +1081,9 @@ private function configureMetrics(array $options): void 'location' => $location !== 'global' ? $location : 'us-central1', ])); - $exporter = new MetricsExporter($this->projectId, $timeoutMillis, $options); + $metricsCredentials = $this->buildMetricsCredentials($options); + + $exporter = new MetricsExporter($metricsCredentials, $this->projectId, $timeoutMillis, $options); $reader = new ExportingReader($exporter); $this->meterProvider = MeterProvider::builder() ->setResource($resource) @@ -1174,4 +1183,24 @@ private function generateClientHash(string $clientUid): string $tenBits = $intVal >> 6; return sprintf('%06x', $tenBits); } + + private function buildMetricsCredentials(array $options): CredentialsWrapper + { + $metricsCredentials = $options['metricsCredentials'] + ?? $options['credentials'] + ?? null; + + $credentialsConfig = [ + 'scopes' => [ + MetricsExporter::MONITORING_WRITE_SCOPE, + MetricsExporter::CLOUD_PLATFORM_SCOPE + ] + ]; + + $universeDomain = $options['universeDomain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + + $credentialsWrapper = $this->createCredentialsWrapper($metricsCredentials, $credentialsConfig, $universeDomain); + + return $credentialsWrapper; + } } diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index f06408628698..3b26ea78525f 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -71,8 +71,9 @@ public function hashDataProvider() public function testExport() { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); - $exporter = new MetricsExporter(self::PROJECT_ID, 100, [], $mockOtlpExporter->reveal()); + $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, 100, [], $mockOtlpExporter->reveal()); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); $resource = ResourceInfo::create(Attributes::create([ @@ -104,9 +105,10 @@ public function testExport() public function testExportCustomTimeout() { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); $timeout = 500; - $exporter = new MetricsExporter(self::PROJECT_ID, $timeout, [], $mockOtlpExporter->reveal()); + $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, $timeout, [], $mockOtlpExporter->reveal()); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); $resource = ResourceInfo::create(Attributes::create([])); @@ -123,11 +125,50 @@ public function testExportCustomTimeout() public function testConstructWithCustomMetricsCredentials() { - $mockCredentials = $this->prophesize(\Google\Auth\FetchAuthTokenInterface::class); - $exporter = new MetricsExporter(self::PROJECT_ID, 5000, [ - 'metricsCredentials' => $mockCredentials->reveal() - ]); + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); + $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, 5000, []); $this->assertInstanceOf(MetricsExporter::class, $exporter); } + + public function testGuzzleMiddlewareAttachesAuthorizationAndQuotaProjectHeaders() + { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); + $mockCredentials->getAuthorizationHeaderCallback()->willReturn(function () { + return ['authorization' => ['Bearer test-metric-token']]; + }); + $mockCredentials->getQuotaProject()->willReturn('test-quota-project-id'); + + $recordedRequests = []; + $mockHandler = new \GuzzleHttp\Handler\MockHandler([ + new \GuzzleHttp\Psr7\Response(200, [], '') + ]); + $handlerStack = \GuzzleHttp\HandlerStack::create($mockHandler); + + $exporter = new MetricsExporter( + $mockCredentials->reveal(), + self::PROJECT_ID, + 5000, + ['handlerStack' => $handlerStack] + ); + + $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); + $resource = ResourceInfo::create(Attributes::create([])); + $point = new NumberDataPoint(1, Attributes::create([]), 1711368000000000000, 1711368060000000000); + $sum = new Sum([$point], Temporality::CUMULATIVE, true); + $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); + + // Access the internal otlpExporter via reflection to trigger HTTP call through transport + $reflection = new ReflectionClass(MetricsExporter::class); + $property = $reflection->getProperty('otlpExporter'); + $property->setAccessible(true); + $otlpExporter = $property->getValue($exporter); + + $otlpExporter->export([$metric]); + + $lastRequest = $mockHandler->getLastRequest(); + $this->assertNotNull($lastRequest); + $this->assertEquals(['Bearer test-metric-token'], $lastRequest->getHeader('authorization')); + $this->assertEquals(['test-quota-project-id'], $lastRequest->getHeader('x-goog-user-project')); + } } From e4c6944ba84f71bb19dca9031858f7518ef28132 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 19 Aug 2026 23:39:08 +0000 Subject: [PATCH 3/6] remove unnecessary project_id parameter from exporter --- Spanner/src/OpenTelemetry/MetricsExporter.php | 9 ----- Spanner/src/SpannerClient.php | 6 +-- .../BuiltInMetricsExporterTest.php | 40 +++++++++++++++---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/Spanner/src/OpenTelemetry/MetricsExporter.php b/Spanner/src/OpenTelemetry/MetricsExporter.php index 45776c964cfb..3ec2da539b0f 100644 --- a/Spanner/src/OpenTelemetry/MetricsExporter.php +++ b/Spanner/src/OpenTelemetry/MetricsExporter.php @@ -33,11 +33,8 @@ namespace Google\Cloud\Spanner\OpenTelemetry; use Google\ApiCore\CredentialsWrapper; -use Google\Auth\ApplicationDefaultCredentials; -use Google\Auth\Middleware\AuthTokenMiddleware; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Request; use OpenTelemetry\Contrib\Otlp\MetricExporter as OtlpMetricExporter; use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; use OpenTelemetry\SDK\Metrics\AggregationTemporalitySelectorInterface; @@ -46,7 +43,6 @@ use OpenTelemetry\SDK\Metrics\MetricMetadataInterface; use OpenTelemetry\SDK\Metrics\PushMetricExporterInterface; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use Throwable; /** @@ -61,25 +57,20 @@ class MetricsExporter implements PushMetricExporterInterface, AggregationTempora public const MONITORING_WRITE_SCOPE = 'https://www.googleapis.com/auth/monitoring.write'; public const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; - private string $projectId; private PushMetricExporterInterface $otlpExporter; /** * @param CredentialsWrapper $metricsCredentials The credentials wrapper for metric export. - * @param string $projectId The GCP project ID metrics will be written to. * @param int $timeoutMillis The timeout defined for the metrics client during export. * @param array $options Optional configuration parameters. * @param PushMetricExporterInterface|null $otlpExporter Optional inner exporter for testing. */ public function __construct( CredentialsWrapper $metricsCredentials, - string $projectId, int $timeoutMillis = 5000, array $options = [], ?PushMetricExporterInterface $otlpExporter = null ) { - $this->projectId = $projectId; - if ($otlpExporter !== null) { $this->otlpExporter = $otlpExporter; return; diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index 8ab25395d269..8fa2949cf862 100644 --- a/Spanner/src/SpannerClient.php +++ b/Spanner/src/SpannerClient.php @@ -1083,7 +1083,7 @@ private function configureMetrics(array $options): void $metricsCredentials = $this->buildMetricsCredentials($options); - $exporter = new MetricsExporter($metricsCredentials, $this->projectId, $timeoutMillis, $options); + $exporter = new MetricsExporter($metricsCredentials, $timeoutMillis, $options); $reader = new ExportingReader($exporter); $this->meterProvider = MeterProvider::builder() ->setResource($resource) @@ -1093,7 +1093,7 @@ private function configureMetrics(array $options): void $this->meter = $this->meterProvider->getMeter('google-cloud-spanner'); ShutdownHandler::register([$this->meterProvider, 'shutdown']); - $attemptMetricsMiddleware = function (MiddlewareInterface $handler) use ($metricsClientId, $location) { + $attemptMetricsMiddleware = function (MiddlewareInterface $handler) use ($metricsClientId) { return new MetricsAttemptMiddleware( $handler, $this->meter, @@ -1102,7 +1102,7 @@ private function configureMetrics(array $options): void ); }; - $operationMetricsMiddleware = function (MiddlewareInterface $handler) use ($metricsClientId, $location) { + $operationMetricsMiddleware = function (MiddlewareInterface $handler) use ($metricsClientId) { return new MetricsOperationMiddleware( $handler, $this->meter, diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index 3b26ea78525f..7395b0b1a153 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -38,7 +38,6 @@ class BuiltInMetricsExporterTest extends TestCase { use ProphecyTrait; - const PROJECT_ID = 'test-project'; const CLIENT_ID = 'test-client-id'; const DEFAULT_TIMEOUT = 100; @@ -73,7 +72,12 @@ public function testExport() { $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); - $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, 100, [], $mockOtlpExporter->reveal()); + $exporter = new MetricsExporter( + $mockCredentials->reveal(), + 100, + [], + $mockOtlpExporter->reveal() + ); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); $resource = ResourceInfo::create(Attributes::create([ @@ -96,7 +100,14 @@ public function testExport() ); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); $mockOtlpExporter->export(Argument::type('iterable'))->shouldBeCalled()->willReturn(true); @@ -108,7 +119,7 @@ public function testExportCustomTimeout() $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); $timeout = 500; - $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, $timeout, [], $mockOtlpExporter->reveal()); + $exporter = new MetricsExporter($mockCredentials->reveal(), $timeout, [], $mockOtlpExporter->reveal()); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); $resource = ResourceInfo::create(Attributes::create([])); @@ -116,7 +127,14 @@ public function testExportCustomTimeout() $attributes = Attributes::create([]); $point = new NumberDataPoint(1, $attributes, 1711368000000000000, 1711368060000000000); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); $mockOtlpExporter->export(Argument::any())->shouldBeCalled()->willReturn(true); @@ -126,7 +144,7 @@ public function testExportCustomTimeout() public function testConstructWithCustomMetricsCredentials() { $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); - $exporter = new MetricsExporter($mockCredentials->reveal(), self::PROJECT_ID, 5000, []); + $exporter = new MetricsExporter($mockCredentials->reveal(), 5000, []); $this->assertInstanceOf(MetricsExporter::class, $exporter); } @@ -147,7 +165,6 @@ public function testGuzzleMiddlewareAttachesAuthorizationAndQuotaProjectHeaders( $exporter = new MetricsExporter( $mockCredentials->reveal(), - self::PROJECT_ID, 5000, ['handlerStack' => $handlerStack] ); @@ -156,7 +173,14 @@ public function testGuzzleMiddlewareAttachesAuthorizationAndQuotaProjectHeaders( $resource = ResourceInfo::create(Attributes::create([])); $point = new NumberDataPoint(1, Attributes::create([]), 1711368000000000000, 1711368060000000000); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric($scope, $resource, 'spanner.googleapis.com/internal/client/attempt_count', '1', 'desc', $sum); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); // Access the internal otlpExporter via reflection to trigger HTTP call through transport $reflection = new ReflectionClass(MetricsExporter::class); From caffa7b0e6f015fb1218a1760b05cd69fdeaeb24 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 27 Aug 2026 22:19:56 +0000 Subject: [PATCH 4/6] marked the metrics exporter as deprecated --- Spanner/src/OpenTelemetry/MetricsExporter.php | 318 +++++++++++++----- .../src/OpenTelemetry/OtlpMetricsExporter.php | 162 +++++++++ Spanner/src/SpannerClient.php | 8 +- .../BuiltInMetricsExporterTest.php | 166 ++++----- .../OpenTelemetry/OtlpMetricsExporterTest.php | 167 +++++++++ 5 files changed, 638 insertions(+), 183 deletions(-) create mode 100644 Spanner/src/OpenTelemetry/OtlpMetricsExporter.php create mode 100644 Spanner/tests/Unit/OpenTelemetry/OtlpMetricsExporterTest.php diff --git a/Spanner/src/OpenTelemetry/MetricsExporter.php b/Spanner/src/OpenTelemetry/MetricsExporter.php index 3ec2da539b0f..64e200599a24 100644 --- a/Spanner/src/OpenTelemetry/MetricsExporter.php +++ b/Spanner/src/OpenTelemetry/MetricsExporter.php @@ -32,131 +32,293 @@ namespace Google\Cloud\Spanner\OpenTelemetry; -use Google\ApiCore\CredentialsWrapper; -use GuzzleHttp\Client as GuzzleClient; -use GuzzleHttp\HandlerStack; -use OpenTelemetry\Contrib\Otlp\MetricExporter as OtlpMetricExporter; -use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; +use Google\Api\Distribution; +use Google\Api\Distribution\BucketOptions; +use Google\Api\Distribution\BucketOptions\Explicit; +use Google\Api\Metric; +use Google\Api\MetricDescriptor\MetricKind; +use Google\Api\MetricDescriptor\ValueType; +use Google\Api\MonitoredResource; +use Google\Cloud\Monitoring\V3\Client\MetricServiceClient; +use Google\Cloud\Monitoring\V3\CreateTimeSeriesRequest; +use Google\Cloud\Monitoring\V3\Point; +use Google\Cloud\Monitoring\V3\TimeInterval; +use Google\Cloud\Monitoring\V3\TimeSeries; +use Google\Cloud\Monitoring\V3\TypedValue; +use Google\Protobuf\Timestamp; use OpenTelemetry\SDK\Metrics\AggregationTemporalitySelectorInterface; +use OpenTelemetry\SDK\Metrics\Data\DataInterface; +use OpenTelemetry\SDK\Metrics\Data\Histogram; +use OpenTelemetry\SDK\Metrics\Data\HistogramDataPoint; use OpenTelemetry\SDK\Metrics\Data\Metric as OTelMetric; +use OpenTelemetry\SDK\Metrics\Data\NumberDataPoint; +use OpenTelemetry\SDK\Metrics\Data\Sum; use OpenTelemetry\SDK\Metrics\Data\Temporality; use OpenTelemetry\SDK\Metrics\MetricMetadataInterface; use OpenTelemetry\SDK\Metrics\PushMetricExporterInterface; -use Psr\Http\Message\RequestInterface; -use Throwable; /** - * MetricsExporter encapsulates the standard OpenTelemetry OTLP MetricExporter - * targeting Google Cloud Telemetry endpoint. + * MetricsExporter exports Spanner client metrics to Google Cloud Monitoring + * using the internal service endpoint. * - * @internal + * @deprecated */ class MetricsExporter implements PushMetricExporterInterface, AggregationTemporalitySelectorInterface { - private const DEFAULT_ENDPOINT = 'https://telemetry.googleapis.com/v1/metrics'; - public const MONITORING_WRITE_SCOPE = 'https://www.googleapis.com/auth/monitoring.write'; - public const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; + private const SPANNER_RESOURCE_TYPE = 'spanner_instance_client'; + private const NATIVE_METRICS_PREFIX = 'spanner.googleapis.com/internal/client/'; + private const SEND_BATCH_SIZE = 200; - private PushMetricExporterInterface $otlpExporter; + /** + * Labels that belong to the MonitoredResource rather than the Metric. + */ + private static array $MONITORED_RES_LABELS = [ + 'project_id' => true, + 'instance_id' => true, + 'instance_config' => true, + 'location' => true, + 'client_hash' => true, + ]; + + private MetricServiceClient $client; + private string $projectId; + private string $clientHash; + private int $timeoutMillis; /** - * @param CredentialsWrapper $metricsCredentials The credentials wrapper for metric export. + * @param MetricServiceClient $client The monitoring client. + * @param string $projectId The GCP project ID metrics will be written to. + * @param string $clientUid The unique client identifier. * @param int $timeoutMillis The timeout defined for the metrics client during export. - * @param array $options Optional configuration parameters. - * @param PushMetricExporterInterface|null $otlpExporter Optional inner exporter for testing. */ - public function __construct( - CredentialsWrapper $metricsCredentials, - int $timeoutMillis = 5000, - array $options = [], - ?PushMetricExporterInterface $otlpExporter = null - ) { - if ($otlpExporter !== null) { - $this->otlpExporter = $otlpExporter; - return; - } - - $authCallback = $metricsCredentials->getAuthorizationHeaderCallback(); - $quotaProject = $metricsCredentials->getQuotaProject(); - $handlerStack = $options['handlerStack'] ?? HandlerStack::create(); - $handlerStack->push(function (callable $handler) use ($authCallback, $quotaProject) { - return function (RequestInterface $request, array $options) use ($authCallback, $handler, $quotaProject) { - $headers = $authCallback ? $authCallback() : []; - foreach ($headers as $name => $values) { - $request = $request->withHeader($name, $values); - } - if ($quotaProject) { - $request = $request->withHeader('x-goog-user-project', $quotaProject); - } - return $handler($request, $options); - }; - }); - - $guzzleClient = $options['guzzleClient'] ?? new GuzzleClient([ - 'handler' => $handlerStack, - 'auth' => 'google_auth', - 'timeout' => $timeoutMillis / 1000, - ]); - - $transport = (new PsrTransportFactory($guzzleClient))->create( - self::DEFAULT_ENDPOINT, - 'application/x-protobuf' - ); - - $this->otlpExporter = new OtlpMetricExporter($transport, Temporality::CUMULATIVE); + public function __construct(MetricServiceClient $client, string $projectId, string $clientUid, int $timeoutMillis) + { + $this->client = $client; + $this->projectId = $projectId; + $this->clientHash = $this->generateClientHash($clientUid); + $this->timeoutMillis = $timeoutMillis; } /** - * Exports a batch of OTel metrics using the inner OTLP exporter. + * Exports a batch of OTel metrics to Cloud Monitoring. * * @param iterable $batch * @return bool */ public function export(iterable $batch): bool { - try { - return $this->otlpExporter->export($batch); - } catch (Throwable $e) { - return false; + $timeSeriesList = []; + foreach ($batch as $otelMetric) { + $timeSeriesList = array_merge($timeSeriesList, $this->mapMetric($otelMetric)); + } + + if (empty($timeSeriesList)) { + return true; } + + $projectName = MetricServiceClient::projectName($this->projectId); + $chunks = array_chunk($timeSeriesList, self::SEND_BATCH_SIZE); + + foreach ($chunks as $chunk) { + $request = new CreateTimeSeriesRequest(); + $request->setName($projectName); + $request->setTimeSeries($chunk); + + try { + $this->client->createServiceTimeSeries($request, [ + 'timeoutMillis' => $this->timeoutMillis + ]); + } catch (\Exception $e) { + // Fail silently during shutdown to avoid user-visible errors. + } + } + + return true; } /** - * Implementation of forceFlush method for PushMetricExporterInterface. + * Implementation of the forcePush method for PushMetricExporter interface. * - * @return bool + * @return true */ public function forceFlush(): bool { - try { - return $this->otlpExporter->forceFlush(); - } catch (Throwable $e) { - return false; - } + return true; } /** - * Implementation of shutdown method for PushMetricExporterInterface. + * Implementation of the shutdown method for PushMetricExporterInterface. * - * @return bool + * @return true */ public function shutdown(): bool { - try { - return $this->otlpExporter->shutdown(); - } catch (Throwable $e) { - return false; - } + $this->client->close(); + return true; } /** * Returns the aggregation temporality for the given metric. * - * @param MetricMetadataInterface $metric - * @return Temporality|string|null + * @param MetricMetadataInterface $metadata + * @return string */ - public function temporality(MetricMetadataInterface $metric): Temporality|string|null + public function temporality(MetricMetadataInterface $metadata): string { return Temporality::CUMULATIVE; } + + /** + * Maps an OTel Metric object to one or more GCM TimeSeries objects. + * + * @param OTelMetric $otelMetric + */ + private function mapMetric(OTelMetric $otelMetric): array + { + $timeSeriesList = []; + $metricType = $this->formatMetricName($otelMetric->name); + + $data = $otelMetric->data; + if ($data instanceof Sum || $data instanceof Histogram) { + foreach ($data->dataPoints as $point) { + $timeSeriesList[] = $this->createTimeSeries($metricType, $point, $otelMetric->unit, $data); + } + } + + return $timeSeriesList; + } + + /** + * Creates a single GCM TimeSeries from an OTel DataPoint. + * + * @param string $metricType + * @param NumberDataPoint|HistogramDataPoint $otelPoint + * @param string|null $unit + * @param DataInterface $otelData + * @return TimeSeries + */ + private function createTimeSeries( + string $metricType, + NumberDataPoint|HistogramDataPoint $otelPoint, + ?string $unit, + DataInterface $otelData + ): TimeSeries { + $ts = new TimeSeries(); + $unit = $unit ?? '1'; + + $metricLabels = []; + $resourceLabels = [ + 'client_hash' => $this->clientHash, + ]; + + // Distribute attributes between Resource and Metric labels + foreach ($otelPoint->attributes as $key => $value) { + $labelKey = str_replace('.', '_', $key); + if (isset(self::$MONITORED_RES_LABELS[$labelKey])) { + $resourceLabels[$labelKey] = (string) $value; + } else { + $metricLabels[$labelKey] = (string) $value; + } + } + + $metric = new Metric(); + $metric->setType($metricType); + $metric->setLabels($metricLabels); + $ts->setMetric($metric); + + $resource = new MonitoredResource(); + $resource->setType(self::SPANNER_RESOURCE_TYPE); + $resource->setLabels($resourceLabels); + $ts->setResource($resource); + + $ts->setUnit($unit); + + $point = new Point(); + $interval = new TimeInterval(); + + // Convert nanoseconds to Protobuf Timestamp + $interval->setStartTime($this->toTimestamp($otelPoint->startTimestamp)); + $interval->setEndTime($this->toTimestamp($otelPoint->timestamp)); + $point->setInterval($interval); + + $value = new TypedValue(); + if ($otelData instanceof Sum) { + $ts->setMetricKind($otelData->monotonic ? MetricKind::CUMULATIVE : MetricKind::GAUGE); + if (is_int($otelPoint->value)) { + $value->setInt64Value($otelPoint->value); + $ts->setValueType(ValueType::INT64); + } else { + $value->setDoubleValue((float) $otelPoint->value); + $ts->setValueType(ValueType::DOUBLE); + } + } elseif ($otelData instanceof Histogram) { + $ts->setMetricKind(MetricKind::CUMULATIVE); + $ts->setValueType(ValueType::DISTRIBUTION); + + $dist = new Distribution(); + $dist->setCount($otelPoint->count); + if ($otelPoint->count > 0) { + $dist->setMean($otelPoint->sum / $otelPoint->count); + } + $dist->setBucketCounts($otelPoint->bucketCounts); + + $bucketOptions = new BucketOptions(); + $explicit = new Explicit(); + $explicit->setBounds($otelPoint->explicitBounds); + $bucketOptions->setExplicitBuckets($explicit); + $dist->setBucketOptions($bucketOptions); + + $value->setDistributionValue($dist); + } + + $point->setValue($value); + $ts->setPoints([$point]); + + return $ts; + } + + /** + * Formats the metric name for Cloud Monitoring. + * Built-in metrics MUST use the specific internal namespace. + * + * @param string $name The OTel instrument name. + * @return string The fully qualified GCM metric type. + */ + private function formatMetricName(string $name): string + { + return self::NATIVE_METRICS_PREFIX . $name; + } + + /** + * Converts nanoseconds to a php Timestamp + * + * @param int $nanos + * @return Timestamp + */ + private function toTimestamp(int $nanos): Timestamp + { + $timestamp = new Timestamp(); + $timestamp->setSeconds((int) ($nanos / 1_000_000_000)); + $timestamp->setNanos((int) ($nanos % 1_000_000_000)); + return $timestamp; + } + + /** + * Returns a hash of the client UUID for the metrics + * + * @param string $clientUid + * @return string + */ + private function generateClientHash(string $clientUid): string + { + if ($clientUid === '') { + return '000000'; + } + + $hashHex = hash('fnv164', $clientUid); + $firstFour = substr($hashHex, 0, 4); + $intVal = hexdec($firstFour); + $tenBits = $intVal >> 6; + return sprintf('%06x', $tenBits); + } } diff --git a/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php b/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php new file mode 100644 index 000000000000..e3689b47954e --- /dev/null +++ b/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php @@ -0,0 +1,162 @@ +otlpExporter = $otlpExporter; + return; + } + + $authCallback = $metricsCredentials->getAuthorizationHeaderCallback(); + $quotaProject = $metricsCredentials->getQuotaProject(); + $handlerStack = $options['handlerStack'] ?? HandlerStack::create(); + $handlerStack->push(function (callable $handler) use ($authCallback, $quotaProject) { + return function (RequestInterface $request, array $options) use ($authCallback, $handler, $quotaProject) { + $headers = $authCallback ? $authCallback() : []; + foreach ($headers as $name => $values) { + $request = $request->withHeader($name, $values); + } + if ($quotaProject) { + $request = $request->withHeader('x-goog-user-project', $quotaProject); + } + return $handler($request, $options); + }; + }); + + $guzzleClient = $options['guzzleClient'] ?? new GuzzleClient([ + 'handler' => $handlerStack, + 'auth' => 'google_auth', + 'timeout' => $timeoutMillis / 1000, + ]); + + $transport = (new PsrTransportFactory($guzzleClient))->create( + self::DEFAULT_ENDPOINT, + 'application/x-protobuf' + ); + + $this->otlpExporter = new OtlpMetricExporter($transport, Temporality::CUMULATIVE); + } + + /** + * Exports a batch of OTel metrics using the inner OTLP exporter. + * + * @param iterable $batch + * @return bool + */ + public function export(iterable $batch): bool + { + try { + return $this->otlpExporter->export($batch); + } catch (Throwable $e) { + return false; + } + } + + /** + * Implementation of forceFlush method for PushMetricExporterInterface. + * + * @return bool + */ + public function forceFlush(): bool + { + try { + return $this->otlpExporter->forceFlush(); + } catch (Throwable $e) { + return false; + } + } + + /** + * Implementation of shutdown method for PushMetricExporterInterface. + * + * @return bool + */ + public function shutdown(): bool + { + try { + return $this->otlpExporter->shutdown(); + } catch (Throwable $e) { + return false; + } + } + + /** + * Returns the aggregation temporality for the given metric. + * + * @param MetricMetadataInterface $metric + * @return Temporality|string|null + */ + public function temporality(MetricMetadataInterface $metric): Temporality|string|null + { + return Temporality::CUMULATIVE; + } +} diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index 8fa2949cf862..dfdfb9a9da47 100644 --- a/Spanner/src/SpannerClient.php +++ b/Spanner/src/SpannerClient.php @@ -47,7 +47,7 @@ use Google\Cloud\Spanner\Middleware\MetricsOperationMiddleware; use Google\Cloud\Spanner\Middleware\RequestIdHeaderMiddleware; use Google\Cloud\Spanner\Middleware\SpannerMiddleware; -use Google\Cloud\Spanner\OpenTelemetry\MetricsExporter; +use Google\Cloud\Spanner\OpenTelemetry\OtlpMetricsExporter; use Google\Cloud\Spanner\V1\Client\SpannerClient as GapicSpannerClient; use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode; @@ -1083,7 +1083,7 @@ private function configureMetrics(array $options): void $metricsCredentials = $this->buildMetricsCredentials($options); - $exporter = new MetricsExporter($metricsCredentials, $timeoutMillis, $options); + $exporter = new OtlpMetricsExporter($metricsCredentials, $timeoutMillis, $options); $reader = new ExportingReader($exporter); $this->meterProvider = MeterProvider::builder() ->setResource($resource) @@ -1192,8 +1192,8 @@ private function buildMetricsCredentials(array $options): CredentialsWrapper $credentialsConfig = [ 'scopes' => [ - MetricsExporter::MONITORING_WRITE_SCOPE, - MetricsExporter::CLOUD_PLATFORM_SCOPE + OtlpMetricsExporter::MONITORING_WRITE_SCOPE, + OtlpMetricsExporter::CLOUD_PLATFORM_SCOPE ] ]; diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index 7395b0b1a153..ed333a157564 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -17,6 +17,8 @@ namespace Google\Cloud\Spanner\Tests\Unit\OpenTelemetry; +use Google\Cloud\Monitoring\V3\Client\MetricServiceClient; +use Google\Cloud\Monitoring\V3\CreateTimeSeriesRequest; use Google\Cloud\Spanner\OpenTelemetry\MetricsExporter; use OpenTelemetry\SDK\Common\Attribute\Attributes; use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScope; @@ -25,7 +27,6 @@ use OpenTelemetry\SDK\Metrics\Data\Sum; use OpenTelemetry\SDK\Metrics\Data\Temporality; use OpenTelemetry\SDK\Resource\ResourceInfo; -use OpenTelemetry\SDK\Metrics\PushMetricExporterInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -38,6 +39,7 @@ class BuiltInMetricsExporterTest extends TestCase { use ProphecyTrait; + const PROJECT_ID = 'test-project'; const CLIENT_ID = 'test-client-id'; const DEFAULT_TIMEOUT = 100; @@ -46,44 +48,35 @@ class BuiltInMetricsExporterTest extends TestCase */ public function testGenerateClientHash($clientUid, $expected) { - $reflection = new ReflectionClass(\Google\Cloud\Spanner\SpannerClient::class); + $client = $this->prophesize(MetricServiceClient::class); + $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, self::DEFAULT_TIMEOUT); + + $reflection = new ReflectionClass(MetricsExporter::class); $method = $reflection->getMethod('generateClientHash'); $method->setAccessible(true); - $client = new ReflectionClass(\Google\Cloud\Spanner\SpannerClient::class); - $instance = $client->newInstanceWithoutConstructor(); - - $result = $method->invoke($instance, $clientUid); + $result = $method->invoke($exporter, $clientUid); $this->assertEquals($expected, $result); } public function hashDataProvider() { return [ - ['exampleUID', '000194'], + ['exampleUID', '00006b'], ['', '000000'], - ['!@#$%^&*()', '000129'], - ['aVeryLongUniqueIdentifierThatExceedsNormalLength', '00008e'], - ['1234567890', '00018d'], + ['!@#$%^&*()', '000389'], + ['aVeryLongUniqueIdentifierThatExceedsNormalLength', '000125'], + ['1234567890', '00003e'], ]; } public function testExport() { - $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); - $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); - $exporter = new MetricsExporter( - $mockCredentials->reveal(), - 100, - [], - $mockOtlpExporter->reveal() - ); + $client = $this->prophesize(MetricServiceClient::class); + $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, 100); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); - $resource = ResourceInfo::create(Attributes::create([ - 'gcp.resource_type' => 'spanner_instance_client', - 'client_hash' => '000212' - ])); + $resource = ResourceInfo::create(Attributes::create(['service.name' => 'spanner'])); $attributes = Attributes::create([ 'method' => 'ExecuteSql', @@ -100,99 +93,70 @@ public function testExport() ); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric( - $scope, - $resource, - 'spanner.googleapis.com/internal/client/attempt_count', - '1', - 'desc', - $sum - ); - - $mockOtlpExporter->export(Argument::type('iterable'))->shouldBeCalled()->willReturn(true); + $metric = new OTelMetric($scope, $resource, 'attempt_count', '1', 'desc', $sum); + + $client->createServiceTimeSeries(Argument::that(function ($request) { + if (!$request instanceof CreateTimeSeriesRequest) { + return false; + } + + $projectName = MetricServiceClient::projectName(self::PROJECT_ID); + if ($request->getName() !== $projectName) { + return false; + } + + $timeSeries = $request->getTimeSeries()[0]; + + // Verify Metric Type + $expectedMetric = 'spanner.googleapis.com/internal/client/attempt_count'; + if ($timeSeries->getMetric()->getType() !== $expectedMetric) { + return false; + } + + // Verify Labels + $labels = $timeSeries->getMetric()->getLabels(); + if ($labels['method'] !== 'ExecuteSql' || + $labels['status'] !== 'OK' || + $labels['database'] !== 'my-db') { + return false; + } + + // Verify Resource + $resLabels = $timeSeries->getResource()->getLabels(); + if ($resLabels['instance_id'] !== 'my-instance') { + return false; + } + + // Verify Client Hash + if ($resLabels['client_hash'] !== '000369') { + return false; + } + + return true; + }), Argument::withEntry('timeoutMillis', 100))->shouldBeCalled(); $this->assertTrue($exporter->export([$metric])); } public function testExportCustomTimeout() { - $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); - $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); + $client = $this->prophesize(MetricServiceClient::class); $timeout = 500; - $exporter = new MetricsExporter($mockCredentials->reveal(), $timeout, [], $mockOtlpExporter->reveal()); + $exporter = new MetricsExporter($client->reveal(), self::PROJECT_ID, self::CLIENT_ID, $timeout); $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); - $resource = ResourceInfo::create(Attributes::create([])); + $resource = ResourceInfo::create(Attributes::create(['service.name' => 'spanner'])); $attributes = Attributes::create([]); $point = new NumberDataPoint(1, $attributes, 1711368000000000000, 1711368060000000000); $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric( - $scope, - $resource, - 'spanner.googleapis.com/internal/client/attempt_count', - '1', - 'desc', - $sum - ); - - $mockOtlpExporter->export(Argument::any())->shouldBeCalled()->willReturn(true); - - $this->assertTrue($exporter->export([$metric])); - } - - public function testConstructWithCustomMetricsCredentials() - { - $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); - $exporter = new MetricsExporter($mockCredentials->reveal(), 5000, []); - - $this->assertInstanceOf(MetricsExporter::class, $exporter); - } - - public function testGuzzleMiddlewareAttachesAuthorizationAndQuotaProjectHeaders() - { - $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); - $mockCredentials->getAuthorizationHeaderCallback()->willReturn(function () { - return ['authorization' => ['Bearer test-metric-token']]; - }); - $mockCredentials->getQuotaProject()->willReturn('test-quota-project-id'); - - $recordedRequests = []; - $mockHandler = new \GuzzleHttp\Handler\MockHandler([ - new \GuzzleHttp\Psr7\Response(200, [], '') - ]); - $handlerStack = \GuzzleHttp\HandlerStack::create($mockHandler); - - $exporter = new MetricsExporter( - $mockCredentials->reveal(), - 5000, - ['handlerStack' => $handlerStack] - ); - - $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); - $resource = ResourceInfo::create(Attributes::create([])); - $point = new NumberDataPoint(1, Attributes::create([]), 1711368000000000000, 1711368060000000000); - $sum = new Sum([$point], Temporality::CUMULATIVE, true); - $metric = new OTelMetric( - $scope, - $resource, - 'spanner.googleapis.com/internal/client/attempt_count', - '1', - 'desc', - $sum - ); - - // Access the internal otlpExporter via reflection to trigger HTTP call through transport - $reflection = new ReflectionClass(MetricsExporter::class); - $property = $reflection->getProperty('otlpExporter'); - $property->setAccessible(true); - $otlpExporter = $property->getValue($exporter); + $metric = new OTelMetric($scope, $resource, 'attempt_count', '1', 'desc', $sum); - $otlpExporter->export([$metric]); + $client->createServiceTimeSeries( + Argument::type(CreateTimeSeriesRequest::class), + Argument::withEntry('timeoutMillis', $timeout) + )->shouldBeCalled(); - $lastRequest = $mockHandler->getLastRequest(); - $this->assertNotNull($lastRequest); - $this->assertEquals(['Bearer test-metric-token'], $lastRequest->getHeader('authorization')); - $this->assertEquals(['test-quota-project-id'], $lastRequest->getHeader('x-goog-user-project')); + $exporter->export([$metric]); } } diff --git a/Spanner/tests/Unit/OpenTelemetry/OtlpMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/OtlpMetricsExporterTest.php new file mode 100644 index 000000000000..cd5c47fdbab2 --- /dev/null +++ b/Spanner/tests/Unit/OpenTelemetry/OtlpMetricsExporterTest.php @@ -0,0 +1,167 @@ +prophesize(\Google\ApiCore\CredentialsWrapper::class); + $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); + $exporter = new OtlpMetricsExporter( + $mockCredentials->reveal(), + 100, + [], + $mockOtlpExporter->reveal() + ); + + $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); + $resource = ResourceInfo::create(Attributes::create([ + 'gcp.resource_type' => 'spanner_instance_client', + 'client_hash' => '000212' + ])); + + $attributes = Attributes::create([ + 'method' => 'ExecuteSql', + 'status' => 'OK', + 'instance_id' => 'my-instance', + 'database' => 'my-db' + ]); + + $point = new NumberDataPoint( + 1, + $attributes, + 1711368000000000000, + 1711368060000000000 + ); + + $sum = new Sum([$point], Temporality::CUMULATIVE, true); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); + + $mockOtlpExporter->export(Argument::type('iterable'))->shouldBeCalled()->willReturn(true); + + $this->assertTrue($exporter->export([$metric])); + } + + public function testExportCustomTimeout() + { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); + $mockOtlpExporter = $this->prophesize(PushMetricExporterInterface::class); + $timeout = 500; + $exporter = new OtlpMetricsExporter($mockCredentials->reveal(), $timeout, [], $mockOtlpExporter->reveal()); + + $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); + $resource = ResourceInfo::create(Attributes::create([])); + + $attributes = Attributes::create([]); + $point = new NumberDataPoint(1, $attributes, 1711368000000000000, 1711368060000000000); + $sum = new Sum([$point], Temporality::CUMULATIVE, true); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); + + $mockOtlpExporter->export(Argument::any())->shouldBeCalled()->willReturn(true); + + $this->assertTrue($exporter->export([$metric])); + } + + public function testConstructWithCustomMetricsCredentials() + { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); + $exporter = new OtlpMetricsExporter($mockCredentials->reveal(), 5000, []); + + $this->assertInstanceOf(OtlpMetricsExporter::class, $exporter); + } + + public function testGuzzleMiddlewareAttachesAuthorizationAndQuotaProjectHeaders() + { + $mockCredentials = $this->prophesize(\Google\ApiCore\CredentialsWrapper::class); + $mockCredentials->getAuthorizationHeaderCallback()->willReturn(function () { + return ['authorization' => ['Bearer test-metric-token']]; + }); + $mockCredentials->getQuotaProject()->willReturn('test-quota-project-id'); + + $mockHandler = new \GuzzleHttp\Handler\MockHandler([ + new \GuzzleHttp\Psr7\Response(200, [], '') + ]); + $handlerStack = \GuzzleHttp\HandlerStack::create($mockHandler); + + $exporter = new OtlpMetricsExporter( + $mockCredentials->reveal(), + 5000, + ['handlerStack' => $handlerStack] + ); + + $scope = new InstrumentationScope('google-cloud-spanner', '1.0.0', null, Attributes::create([])); + $resource = ResourceInfo::create(Attributes::create([])); + $point = new NumberDataPoint(1, Attributes::create([]), 1711368000000000000, 1711368060000000000); + $sum = new Sum([$point], Temporality::CUMULATIVE, true); + $metric = new OTelMetric( + $scope, + $resource, + 'spanner.googleapis.com/internal/client/attempt_count', + '1', + 'desc', + $sum + ); + + // Access the internal otlpExporter via reflection to trigger HTTP call through transport + $reflection = new ReflectionClass(OtlpMetricsExporter::class); + $property = $reflection->getProperty('otlpExporter'); + $property->setAccessible(true); + $otlpExporter = $property->getValue($exporter); + + $otlpExporter->export([$metric]); + + $lastRequest = $mockHandler->getLastRequest(); + $this->assertNotNull($lastRequest); + $this->assertEquals(['Bearer test-metric-token'], $lastRequest->getHeader('authorization')); + $this->assertEquals(['test-quota-project-id'], $lastRequest->getHeader('x-goog-user-project')); + } +} From 1e0814763a8242083a3b0efdc8e99422129be78c Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 27 Aug 2026 22:54:46 +0000 Subject: [PATCH 5/6] moved the exporter logic and deprecated the old exporter --- .../OpenTelemetry/BuiltInMetricsExporterTest.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index ed333a157564..19176f8b0753 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -1,4 +1,5 @@ markTestSkipped('Google\Cloud\Monitoring\V3\Client\MetricServiceClient class is not available.'); + } + } + /** * @dataProvider hashDataProvider */ @@ -115,9 +124,11 @@ public function testExport() // Verify Labels $labels = $timeSeries->getMetric()->getLabels(); - if ($labels['method'] !== 'ExecuteSql' || + if ( + $labels['method'] !== 'ExecuteSql' || $labels['status'] !== 'OK' || - $labels['database'] !== 'my-db') { + $labels['database'] !== 'my-db' + ) { return false; } From f6f2716bc4c37bbc50af27a18cc3e8554427e050 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Fri, 28 Aug 2026 19:38:37 +0000 Subject: [PATCH 6/6] remove unnecesary configuration --- Spanner/src/OpenTelemetry/OtlpMetricsExporter.php | 2 +- .../tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php b/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php index e3689b47954e..88d6f7796111 100644 --- a/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php +++ b/Spanner/src/OpenTelemetry/OtlpMetricsExporter.php @@ -92,7 +92,7 @@ public function __construct( }; }); - $guzzleClient = $options['guzzleClient'] ?? new GuzzleClient([ + $guzzleClient = new GuzzleClient([ 'handler' => $handlerStack, 'auth' => 'google_auth', 'timeout' => $timeoutMillis / 1000, diff --git a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php index 19176f8b0753..a6f3c1713e21 100644 --- a/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php +++ b/Spanner/tests/Unit/OpenTelemetry/BuiltInMetricsExporterTest.php @@ -124,8 +124,7 @@ public function testExport() // Verify Labels $labels = $timeSeries->getMetric()->getLabels(); - if ( - $labels['method'] !== 'ExecuteSql' || + if ($labels['method'] !== 'ExecuteSql' || $labels['status'] !== 'OK' || $labels['database'] !== 'my-db' ) {