Skip to content

Commit 9f33eaa

Browse files
committed
Add the builtin metrics feature
1 parent 6796b74 commit 9f33eaa

12 files changed

Lines changed: 1340 additions & 3 deletions

Spanner/composer.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"php": "^8.1",
88
"ext-grpc": "*",
99
"google/cloud-core": "^1.68",
10-
"google/gax": "^1.40.0"
10+
"google/gax": "^1.40.0",
11+
"google/cloud-monitoring": "^2.2",
12+
"open-telemetry/sdk": "^1.13"
1113
},
1214
"require-dev": {
1315
"phpunit/phpunit": "^9.6",
@@ -62,5 +64,11 @@
6264
"@test-snippets",
6365
"@test-system"
6466
]
67+
},
68+
"config": {
69+
"allow-plugins": {
70+
"php-http/discovery": false,
71+
"tbachert/spi": false
72+
}
6573
}
6674
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<?php
2+
/*
3+
* Copyright 2026 Google LLC
4+
* All rights reserved.
5+
*
6+
* Redistribution and use in source and binary forms, with or without
7+
* modification, are permitted provided that the following conditions are
8+
* met:
9+
*
10+
* * Redistributions of source code must retain the above copyright
11+
* notice, this list of conditions and the following disclaimer.
12+
* * Redistributions in binary form must reproduce the above
13+
* copyright notice, this list of conditions and the following disclaimer
14+
* in the documentation and/or other materials provided with the
15+
* distribution.
16+
* * Neither the name of Google Inc. nor the names of its
17+
* contributors may be used to endorse or promote products derived from
18+
* this software without specific prior written permission.
19+
*
20+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31+
*/
32+
33+
namespace Google\Cloud\Spanner\Middleware;
34+
35+
use Exception;
36+
use Google\ApiCore\ApiException;
37+
use Google\ApiCore\Call;
38+
use Google\ApiCore\Middleware\MiddlewareInterface;
39+
use Google\ApiCore\ServerStream;
40+
use Google\Rpc\Code;
41+
use GuzzleHttp\Promise\PromiseInterface;
42+
use OpenTelemetry\API\Metrics\CounterInterface;
43+
use OpenTelemetry\API\Metrics\HistogramInterface;
44+
use OpenTelemetry\API\Metrics\MeterInterface;
45+
46+
/**
47+
* @internal
48+
*
49+
* A middleware to be appended inside of the retry loops of GAX.
50+
* This middleware handles the recording of Built-in metrics
51+
* for an Attempt, where an Attempt is an RPC call as part of an Operation.
52+
* If an Operation fails, said Operation may contain multiple attempts depending on the retry configuration.
53+
*/
54+
class BuiltInMetricsAttemptMiddleware implements MiddlewareInterface
55+
{
56+
private HistogramInterface $attemptLatencyHistogram;
57+
private CounterInterface $attemptCountCounter;
58+
private HistogramInterface $attemptGfeHistogram;
59+
private CounterInterface $gfeConnectivityErrorCounter;
60+
61+
/** @var callable */
62+
private $nextHandler;
63+
private string $projectId;
64+
private string $clientId;
65+
private string $clientName;
66+
67+
private const INSTANCE_CONFIG = 'unknown';
68+
private const LOCATION_LABEL = 'global';
69+
70+
/**
71+
* Creates a middleware that tracks all the attempts of an RPC call
72+
*
73+
* @param callable $nextHandler
74+
* @param MeterInterface $meter
75+
* @param string $clientId
76+
* @param string $projectId
77+
* @param string $clientName
78+
*/
79+
public function __construct(
80+
callable $nextHandler,
81+
MeterInterface $meter,
82+
string $clientId,
83+
string $projectId,
84+
string $clientName,
85+
) {
86+
$this->nextHandler = $nextHandler;
87+
$this->attemptLatencyHistogram = $meter->createHistogram(
88+
'attempt_latencies',
89+
'ms',
90+
'The latency of an RPC attempt'
91+
);
92+
$this->attemptCountCounter = $meter->createCounter(
93+
'attempt_count',
94+
'1',
95+
'The number of RPC attempts'
96+
);
97+
$this->attemptGfeHistogram = $meter->createHistogram(
98+
'gfe_latencies',
99+
'ms',
100+
'Latency between Google\'s network receiving an RPC and reading back the first byte of the response'
101+
);
102+
$this->gfeConnectivityErrorCounter = $meter->createCounter(
103+
'gfe_connectivity_error_count',
104+
'1',
105+
'Number of RPC attempts that failed to reach the GFE or returned no GFE headers'
106+
);
107+
$this->clientId = $clientId;
108+
$this->projectId = $projectId;
109+
$this->clientName = $clientName;
110+
}
111+
112+
public function __invoke(Call $call, array $options)
113+
{
114+
$next = $this->nextHandler;
115+
116+
$startTime = microtime(true);
117+
118+
// In case that something else is using this callback,
119+
// we take the original one and call it later.
120+
$originalCallback = $options['metadataCallback'] ?? null;
121+
122+
// This gets the metadata on an ok status meaning we can get the GFE latency header for unary calls
123+
$options['metadataCallback'] = function($metadata) use ($originalCallback, $call, $options) {
124+
$this->recordGfeLatency($metadata, $call, $options);
125+
if ($originalCallback) {
126+
$originalCallback($metadata);
127+
}
128+
};
129+
130+
try {
131+
$response = $next(
132+
$call,
133+
$options
134+
);
135+
} catch (Exception $e) {
136+
// In case that the call is not a unary call and it is a streaming call error.
137+
$this->recordAttempt($startTime,$e->getCode(), $call->getMethod(), $options);
138+
$this->recordGfeError($e, $call, $options);
139+
throw $e;
140+
}
141+
142+
if ($response instanceof ServerStream) {
143+
$this->recordAttempt($startTime, Code::OK, $call->getMethod(), $options);
144+
$this->recordGfeLatency($response->getServerStreamingCall()->getMetadata(), $call, $options);
145+
}
146+
147+
if ($response instanceof PromiseInterface) {
148+
return $response->then(
149+
function ($response) use ($startTime, $options, $call) {
150+
$this->recordAttempt($startTime, Code::OK, $call->getMethod(), $options);
151+
return $response;
152+
},
153+
function ($e) use ($startTime, $options, $call) {
154+
$this->recordAttempt($startTime, $e->getCode(), $call->getMethod(), $options);
155+
$this->recordGfeError($e, $call, $options);
156+
throw $e;
157+
}
158+
);
159+
}
160+
161+
// The response can be a stream
162+
return $response;
163+
}
164+
165+
/**
166+
* Records an Attempt
167+
*
168+
* @param array $options The options being used for the middleware layer to communicate amongst middlewares
169+
* @param float $startTime The start time of the RPC attempt
170+
* @param string $code The resulting code of the attempt
171+
* @param string $method The RPC method name that is being called
172+
*
173+
* @return void
174+
*/
175+
private function recordAttempt(float $startTime, int $code, string $method, array $options): void
176+
{
177+
$endTime = microtime(true);
178+
$duration = ($endTime - $startTime) * 1000; // Convert to MS
179+
180+
$labels = $this->getMetricLabels($method, $options, $code);
181+
182+
$this->attemptCountCounter->add(1, $labels);
183+
$this->attemptLatencyHistogram->record($duration, $labels);
184+
}
185+
186+
private function recordGfeLatency($metadata, Call $call, array $options): void
187+
{
188+
$serverTiming = $metadata['server-timing'][0] ?? null;
189+
$gfeLatency = null;
190+
191+
if ($serverTiming) {
192+
if (preg_match('/gfet4t7;\s*dur=(\d+(\.\d+)?)/', $serverTiming, $matches)) {
193+
$gfeLatency = (float) $matches[1];
194+
}
195+
}
196+
197+
$labels = $this->getMetricLabels($call->getMethod(), $options, Code::OK);
198+
199+
if ($gfeLatency !== null) {
200+
$this->attemptGfeHistogram->record($gfeLatency, $labels);
201+
} else {
202+
$this->gfeConnectivityErrorCounter->add(1, $labels);
203+
}
204+
}
205+
206+
private function getMetricLabels(string $method, array $options, int $code): array
207+
{
208+
$codeName = Code::name($code);
209+
210+
// Extract resource information from the GAX routing header.
211+
$params = $options['headers']['x-goog-request-params'][0] ?? '';
212+
$prefix = urldecode($params);
213+
214+
if (preg_match('/instances\/([^\/]+)\/databases\/([^\/]+)/', $prefix, $matches)) {
215+
$instanceId = $matches[1];
216+
$databaseId = $matches[2];
217+
}
218+
219+
return [
220+
'method' => $method,
221+
'status' => $codeName,
222+
'instance_id' => $instanceId ?? '',
223+
'database' => $databaseId ?? '',
224+
'project_id' => $this->projectId,
225+
'client_uid' => $this->clientId,
226+
'client_name' => $this->clientName,
227+
'instance_config' => self::INSTANCE_CONFIG,
228+
'location' => self::LOCATION_LABEL
229+
];
230+
}
231+
232+
private function recordGfeError(Exception $e, Call $call, array $options): void
233+
{
234+
if ($e instanceof ApiException) {
235+
$this->recordGfeLatency($e->getMetadata() ?? [], $call, $options);
236+
} else {
237+
$this->recordGfeLatency([], $call, $options);
238+
}
239+
}
240+
}

0 commit comments

Comments
 (0)