Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit 07b3295

Browse files
committed
feat: added metrics interceptor
1 parent 6fa3008 commit 07b3295

6 files changed

Lines changed: 531 additions & 22 deletions

File tree

google/cloud/bigtable/data/_async/client.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,24 @@
9797
)
9898
from google.cloud.bigtable.data._async.mutations_batcher import _MB_SIZE
9999
from google.cloud.bigtable.data._async._swappable_channel import (
100-
AsyncSwappableChannel,
100+
AsyncSwappableChannel as SwappableChannelType,
101+
)
102+
from google.cloud.bigtable.data._async.metrics_interceptor import (
103+
AsyncBigtableMetricsInterceptor as MetricInterceptorType,
101104
)
102105
else:
103106
from typing import Iterable # noqa: F401
104107
from grpc import insecure_channel
108+
from grpc import intercept_channel
105109
from google.cloud.bigtable_v2.services.bigtable.transports import BigtableGrpcTransport as TransportType # type: ignore
106110
from google.cloud.bigtable_v2.services.bigtable import BigtableClient as GapicClient # type: ignore
107111
from google.cloud.bigtable.data._sync_autogen.mutations_batcher import _MB_SIZE
108112
from google.cloud.bigtable.data._sync_autogen._swappable_channel import ( # noqa: F401
109-
SwappableChannel,
113+
SwappableChannel as SwappableChannelType,
114+
)
115+
from google.cloud.bigtable.data._sync_autogen.metrics_interceptor import ( # noqa: F401
116+
BigtableMetricsInterceptor as MetricInterceptorType,
110117
)
111-
112118

113119
if TYPE_CHECKING:
114120
from google.cloud.bigtable.data._helpers import RowKeySamples
@@ -203,7 +209,7 @@ def __init__(
203209
credentials = google.auth.credentials.AnonymousCredentials()
204210
if project is None:
205211
project = _DEFAULT_BIGTABLE_EMULATOR_CLIENT
206-
212+
self._metrics_interceptor = MetricInterceptorType()
207213
# initialize client
208214
ClientWithProject.__init__(
209215
self,
@@ -257,12 +263,11 @@ def __init__(
257263
stacklevel=2,
258264
)
259265

260-
@CrossSync.convert(replace_symbols={"AsyncSwappableChannel": "SwappableChannel"})
261-
def _build_grpc_channel(self, *args, **kwargs) -> AsyncSwappableChannel:
266+
def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannelType:
262267
"""
263268
This method is called by the gapic transport to create a grpc channel.
264269
265-
The init arguments passed down are captured in a partial used by AsyncSwappableChannel
270+
The init arguments passed down are captured in a partial used by SwappableChannel
266271
to create new channel instances in the future, as part of the channel refresh logic
267272
268273
Emulators always use an inseucre channel
@@ -276,9 +281,22 @@ def _build_grpc_channel(self, *args, **kwargs) -> AsyncSwappableChannel:
276281
if self._emulator_host is not None:
277282
# emulators use insecure channel
278283
create_channel_fn = partial(insecure_channel, self._emulator_host)
279-
else:
284+
elif CrossSync.is_async:
280285
create_channel_fn = partial(TransportType.create_channel, *args, **kwargs)
281-
return AsyncSwappableChannel(create_channel_fn)
286+
else:
287+
# attach sync interceptors in create_channel_fn
288+
def create_channel_fn():
289+
return intercept_channel(
290+
TransportType.create_channel(*args, **kwargs),
291+
self._metrics_interceptor,
292+
)
293+
294+
new_channel = SwappableChannelType(create_channel_fn)
295+
if CrossSync.is_async:
296+
# attach async interceptors
297+
new_channel._unary_unary_interceptors.append(self._metrics_interceptor)
298+
new_channel._unary_stream_interceptors.append(self._metrics_interceptor)
299+
return new_channel
282300

283301
@property
284302
def universe_domain(self) -> str:
@@ -400,7 +418,6 @@ def _invalidate_channel_stubs(self):
400418
self.transport._stubs = {}
401419
self.transport._prep_wrapped_messages(self.client_info)
402420

403-
@CrossSync.convert(replace_symbols={"AsyncSwappableChannel": "SwappableChannel"})
404421
async def _manage_channel(
405422
self,
406423
refresh_interval_min: float = 60 * 35,
@@ -425,10 +442,10 @@ async def _manage_channel(
425442
grace_period: time to allow previous channel to serve existing
426443
requests before closing, in seconds
427444
"""
428-
if not isinstance(self.transport.grpc_channel, AsyncSwappableChannel):
445+
if not isinstance(self.transport.grpc_channel, SwappableChannelType):
429446
warnings.warn("Channel does not support auto-refresh.")
430447
return
431-
super_channel: AsyncSwappableChannel = self.transport.grpc_channel
448+
super_channel: SwappableChannelType = self.transport.grpc_channel
432449
first_refresh = self._channel_init_time + random.uniform(
433450
refresh_interval_min, refresh_interval_max
434451
)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License
14+
from __future__ import annotations
15+
16+
from google.cloud.bigtable.data._cross_sync import CrossSync
17+
18+
if CrossSync.is_async:
19+
from grpc.aio import UnaryUnaryClientInterceptor
20+
from grpc.aio import UnaryStreamClientInterceptor
21+
else:
22+
from grpc import UnaryUnaryClientInterceptor
23+
from grpc import UnaryStreamClientInterceptor
24+
25+
26+
__CROSS_SYNC_OUTPUT__ = "google.cloud.bigtable.data._sync_autogen.metrics_interceptor"
27+
28+
29+
@CrossSync.convert_class(sync_name="BigtableMetricsInterceptor")
30+
class AsyncBigtableMetricsInterceptor(
31+
UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor
32+
):
33+
"""
34+
An async gRPC interceptor to add client metadata and print server metadata.
35+
"""
36+
37+
def __init__(self):
38+
super().__init__()
39+
self.operation_map = {}
40+
41+
def register_operation(self, operation):
42+
"""
43+
Register an operation object to be tracked my the interceptor
44+
45+
When registered, the operation will receive metadata updates:
46+
- start_attempt if attempt not started when rpc is being sent
47+
- add_response_metadata after call is complete
48+
- end_attempt_with_status if attempt receives an error
49+
50+
The interceptor will register itself as a handeler for the operation,
51+
so it can unregister the operation when it is complete
52+
"""
53+
self.operation_map[operation.uuid] = operation
54+
operation.handlers.append(self)
55+
56+
def on_operation_complete(self, op):
57+
if op.uuid in self.operation_map:
58+
del self.operation_map[op.uuid]
59+
60+
def on_operation_cancelled(self, op):
61+
self.on_operation_complete(op)
62+
63+
@CrossSync.convert
64+
async def intercept_unary_unary(self, continuation, client_call_details, request):
65+
try:
66+
call = await continuation(client_call_details, request)
67+
return call
68+
except Exception as rpc_error:
69+
raise rpc_error
70+
71+
@CrossSync.convert
72+
async def intercept_unary_stream(self, continuation, client_call_details, request):
73+
async def response_wrapper(call):
74+
try:
75+
async for response in call:
76+
yield response
77+
except Exception as e:
78+
# handle errors while processing stream
79+
raise e
80+
81+
try:
82+
return response_wrapper(await continuation(client_call_details, request))
83+
except Exception as rpc_error:
84+
# handle errors while intializing stream
85+
raise rpc_error

google/cloud/bigtable/data/_sync_autogen/client.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,18 @@
7575
from google.cloud.bigtable.data._cross_sync import CrossSync
7676
from typing import Iterable
7777
from grpc import insecure_channel
78+
from grpc import intercept_channel
7879
from google.cloud.bigtable_v2.services.bigtable.transports import (
7980
BigtableGrpcTransport as TransportType,
8081
)
8182
from google.cloud.bigtable_v2.services.bigtable import BigtableClient as GapicClient
8283
from google.cloud.bigtable.data._sync_autogen.mutations_batcher import _MB_SIZE
83-
from google.cloud.bigtable.data._sync_autogen._swappable_channel import SwappableChannel
84+
from google.cloud.bigtable.data._sync_autogen._swappable_channel import (
85+
SwappableChannel as SwappableChannelType,
86+
)
87+
from google.cloud.bigtable.data._sync_autogen.metrics_interceptor import (
88+
BigtableMetricsInterceptor as MetricInterceptorType,
89+
)
8490

8591
if TYPE_CHECKING:
8692
from google.cloud.bigtable.data._helpers import RowKeySamples
@@ -143,6 +149,7 @@ def __init__(
143149
credentials = google.auth.credentials.AnonymousCredentials()
144150
if project is None:
145151
project = _DEFAULT_BIGTABLE_EMULATOR_CLIENT
152+
self._metrics_interceptor = MetricInterceptorType()
146153
ClientWithProject.__init__(
147154
self,
148155
credentials=credentials,
@@ -186,7 +193,7 @@ def __init__(
186193
stacklevel=2,
187194
)
188195

189-
def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannel:
196+
def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannelType:
190197
"""This method is called by the gapic transport to create a grpc channel.
191198
192199
The init arguments passed down are captured in a partial used by SwappableChannel
@@ -202,8 +209,15 @@ def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannel:
202209
if self._emulator_host is not None:
203210
create_channel_fn = partial(insecure_channel, self._emulator_host)
204211
else:
205-
create_channel_fn = partial(TransportType.create_channel, *args, **kwargs)
206-
return SwappableChannel(create_channel_fn)
212+
213+
def create_channel_fn():
214+
return intercept_channel(
215+
TransportType.create_channel(*args, **kwargs),
216+
self._metrics_interceptor,
217+
)
218+
219+
new_channel = SwappableChannelType(create_channel_fn)
220+
return new_channel
207221

208222
@property
209223
def universe_domain(self) -> str:
@@ -302,7 +316,7 @@ def _invalidate_channel_stubs(self):
302316
self.transport._stubs = {}
303317
self.transport._prep_wrapped_messages(self.client_info)
304318

305-
def _manage_channel(
319+
async def _manage_channel(
306320
self,
307321
refresh_interval_min: float = 60 * 35,
308322
refresh_interval_max: float = 60 * 45,
@@ -324,25 +338,25 @@ def _manage_channel(
324338
between `refresh_interval_min` and `refresh_interval_max`
325339
grace_period: time to allow previous channel to serve existing
326340
requests before closing, in seconds"""
327-
if not isinstance(self.transport.grpc_channel, SwappableChannel):
341+
if not isinstance(self.transport.grpc_channel, SwappableChannelType):
328342
warnings.warn("Channel does not support auto-refresh.")
329343
return
330-
super_channel: SwappableChannel = self.transport.grpc_channel
344+
super_channel: SwappableChannelType = self.transport.grpc_channel
331345
first_refresh = self._channel_init_time + random.uniform(
332346
refresh_interval_min, refresh_interval_max
333347
)
334348
next_sleep = max(first_refresh - time.monotonic(), 0)
335349
if next_sleep > 0:
336-
self._ping_and_warm_instances(channel=super_channel)
350+
await self._ping_and_warm_instances(channel=super_channel)
337351
while not self._is_closed.is_set():
338-
CrossSync._Sync_Impl.event_wait(
352+
await CrossSync._Sync_Impl.event_wait(
339353
self._is_closed, next_sleep, async_break_early=False
340354
)
341355
if self._is_closed.is_set():
342356
break
343357
start_timestamp = time.monotonic()
344358
new_channel = super_channel.create_channel()
345-
self._ping_and_warm_instances(channel=new_channel)
359+
await self._ping_and_warm_instances(channel=new_channel)
346360
old_channel = super_channel.swap_channel(new_channel)
347361
self._invalidate_channel_stubs()
348362
if grace_period:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License
14+
15+
# This file is automatically generated by CrossSync. Do not edit manually.
16+
17+
from __future__ import annotations
18+
from grpc import UnaryUnaryClientInterceptor
19+
from grpc import UnaryStreamClientInterceptor
20+
21+
22+
class BigtableMetricsInterceptor(
23+
UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor
24+
):
25+
"""
26+
An async gRPC interceptor to add client metadata and print server metadata.
27+
"""
28+
29+
def __init__(self):
30+
super().__init__()
31+
self.operation_map = {}
32+
33+
def register_operation(self, operation):
34+
"""Register an operation object to be tracked my the interceptor
35+
36+
When registered, the operation will receive metadata updates:
37+
- start_attempt if attempt not started when rpc is being sent
38+
- add_response_metadata after call is complete
39+
- end_attempt_with_status if attempt receives an error
40+
41+
The interceptor will register itself as a handeler for the operation,
42+
so it can unregister the operation when it is complete"""
43+
self.operation_map[operation.uuid] = operation
44+
operation.handlers.append(self)
45+
46+
def on_operation_complete(self, op):
47+
if op.uuid in self.operation_map:
48+
del self.operation_map[op.uuid]
49+
50+
def on_operation_cancelled(self, op):
51+
self.on_operation_complete(op)
52+
53+
def intercept_unary_unary(self, continuation, client_call_details, request):
54+
try:
55+
call = continuation(client_call_details, request)
56+
return call
57+
except Exception as rpc_error:
58+
raise rpc_error
59+
60+
def intercept_unary_stream(self, continuation, client_call_details, request):
61+
def response_wrapper(call):
62+
try:
63+
for response in call:
64+
yield response
65+
except Exception as e:
66+
raise e
67+
68+
try:
69+
return response_wrapper(continuation(client_call_details, request))
70+
except Exception as rpc_error:
71+
raise rpc_error

0 commit comments

Comments
 (0)