Skip to content

Commit db8d528

Browse files
daniel-sanchegcf-owl-bot[bot]mutianf
authored
feat: add data model for client side metrics (#1187)
This PR revives googleapis/python-bigtable#923, which was de-priotirized to work on the sync client. This PR brings it back, working with both async and sync. It also adds a grpc interceptor, as an improved way to capture metadata across both clients --- ## Design The main architecture looks like this: <img width="651" height="631" alt="300137129-bebbb05a-20f0-45c2-9d38-e95a314edf64 drawio (1)" src="https://github.com/user-attachments/assets/c8318ac8-5f18-4027-9f64-4a40a8ab1d79" /> Most of the work is done by the ActiveOperationMetric class, which is instantiated with each rpc call, and updated through the lifecycle of the call. When the rpc is complete, it will call `on_operation_complete` and `on_attempt_complete` on the MetricsHandler, which can then log the completed data into OpenTelemetry (or theoretically, other locations if needed) Note that there are separate classes for active vs completed metrics (`ActiveOperationMetric`, `ActiveAttemptMetric`, `CompletedOperationMetric`, `CompletedAttemptMetric`). This is so that we can keep fields mutable and optional while the request is ongoing, but pass down static immutable copies once the attempt is completed and no new data is coming --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com> Co-authored-by: Mattie Fu <mattiefu@google.com>
1 parent a1e74dd commit db8d528

20 files changed

Lines changed: 2555 additions & 40 deletions

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
fromgoogle.cloud.bigtable.data.row_filtersimportStripValueTransformerFilter
8989
fromgoogle.cloud.bigtable.data.row_filtersimportCellsRowLimitFilter
9090
fromgoogle.cloud.bigtable.data.row_filtersimportRowFilterChain
91+
fromgoogle.cloud.bigtable.data._metricsimportBigtableClientSideMetricsController
9192

9293
fromgoogle.cloud.bigtable.data._cross_syncimportCrossSync
9394

@@ -1039,6 +1040,8 @@ def __init__(
10391040
default_retryable_errorsor ()
10401041
)
10411042

1043+
self._metrics=BigtableClientSideMetricsController()
1044+
10421045
try:
10431046
self._register_instance_future=CrossSync.create_task(
10441047
self.client._register_instance,
@@ -1753,6 +1756,7 @@ async def close(self):
17531756
"""
17541757
Called to close the Table instance and release any resources held by it.
17551758
"""
1759+
self._metrics.close()
17561760
ifself._register_instance_future:
17571761
self._register_instance_future.cancel()
17581762
self.client._remove_instance_registration(

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/metrics_interceptor.py‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,21 @@
1313
# limitations under the License.
1414
from __future__ importannotations
1515

16+
fromtypingimportSequence
17+
18+
importtime
19+
fromfunctoolsimportwraps
20+
21+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportActiveOperationMetric
22+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportOperationState
23+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportOperationType
24+
1625
fromgoogle.cloud.bigtable.data._cross_syncimportCrossSync
1726

1827
ifCrossSync.is_async:
1928
fromgrpc.aioimportUnaryUnaryClientInterceptor
2029
fromgrpc.aioimportUnaryStreamClientInterceptor
30+
fromgrpc.aioimportAioRpcError
2131
else:
2232
fromgrpcimportUnaryUnaryClientInterceptor
2333
fromgrpcimportUnaryStreamClientInterceptor
@@ -26,6 +36,57 @@
2636
__CROSS_SYNC_OUTPUT__="google.cloud.bigtable.data._sync_autogen.metrics_interceptor"
2737

2838

39+
def_with_active_operation(func):
40+
"""
41+
Decorator for interceptor methods to extract the active operation associated with the
42+
in-scope contextvars, and pass it to the decorated function.
43+
"""
44+
45+
@wraps(func)
46+
defwrapper(self, continuation, client_call_details, request):
47+
operation: ActiveOperationMetric|None=ActiveOperationMetric.from_context()
48+
49+
ifoperation:
50+
# start a new attempt if not started
51+
if (
52+
operation.state==OperationState.CREATED
53+
oroperation.state==OperationState.BETWEEN_ATTEMPTS
54+
):
55+
operation.start_attempt()
56+
# wrap continuation in logic to process the operation
57+
returnfunc(self, operation, continuation, client_call_details, request)
58+
else:
59+
# if operation not found, return unwrapped continuation
60+
returncontinuation(client_call_details, request)
61+
62+
returnwrapper
63+
64+
65+
@CrossSync.convert
66+
asyncdef_get_metadata(source) ->dict[str, str|bytes] |None:
67+
"""Helper to extract metadata from a call or RpcError"""
68+
try:
69+
metadata: Sequence[tuple[str, str|bytes]]
70+
ifCrossSync.is_async:
71+
# grpc.aio returns metadata in Metadata objects
72+
ifisinstance(source, AioRpcError):
73+
metadata=list(source.trailing_metadata()) +list(
74+
source.initial_metadata()
75+
)
76+
else:
77+
metadata=list(awaitsource.trailing_metadata()) +list(
78+
awaitsource.initial_metadata()
79+
)
80+
else:
81+
# sync grpc returns metadata as a sequence of tuples
82+
metadata=source.trailing_metadata() +source.initial_metadata()
83+
# convert metadata to dict format
84+
return {k: vfor (k, v) inmetadata}
85+
exceptException:
86+
# ignore errors while fetching metadata
87+
returnNone
88+
89+
2990
@CrossSync.convert_class(sync_name="BigtableMetricsInterceptor")
3091
classAsyncBigtableMetricsInterceptor(
3192
UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor
@@ -35,21 +96,33 @@ class AsyncBigtableMetricsInterceptor(
3596
"""
3697

3798
@CrossSync.convert
38-
asyncdefintercept_unary_unary(self, continuation, client_call_details, request):
99+
@_with_active_operation
100+
asyncdefintercept_unary_unary(
101+
self, operation, continuation, client_call_details, request
102+
):
39103
"""
40104
Interceptor for unary rpcs:
41105
- MutateRow
42106
- CheckAndMutateRow
43107
- ReadModifyWriteRow
44108
"""
109+
metadata=None
45110
try:
46111
call=awaitcontinuation(client_call_details, request)
112+
metadata=await_get_metadata(call)
47113
returncall
48114
exceptExceptionasrpc_error:
115+
metadata=await_get_metadata(rpc_error)
49116
raiserpc_error
117+
finally:
118+
ifmetadataisnotNone:
119+
operation.add_response_metadata(metadata)
50120

51121
@CrossSync.convert
52-
asyncdefintercept_unary_stream(self, continuation, client_call_details, request):
122+
@_with_active_operation
123+
asyncdefintercept_unary_stream(
124+
self, operation, continuation, client_call_details, request
125+
):
53126
"""
54127
Interceptor for streaming rpcs:
55128
- ReadRows
@@ -58,21 +131,42 @@ async def intercept_unary_stream(self, continuation, client_call_details, reques
58131
"""
59132
try:
60133
returnself._streaming_generator_wrapper(
61-
awaitcontinuation(client_call_details, request)
134+
operation, awaitcontinuation(client_call_details, request)
62135
)
63136
exceptExceptionasrpc_error:
64137
# handle errors while intializing stream
138+
metadata=await_get_metadata(rpc_error)
139+
ifmetadataisnotNone:
140+
operation.add_response_metadata(metadata)
65141
raiserpc_error
66142

67143
@staticmethod
68144
@CrossSync.convert
69-
asyncdef_streaming_generator_wrapper(call):
145+
asyncdef_streaming_generator_wrapper(operation, call):
70146
"""
71147
Wrapped generator to be returned by intercept_unary_stream.
72148
"""
149+
# only track has_first response for READ_ROWS
150+
has_first_response= (
151+
operation.first_response_latency_nsisnotNone
152+
oroperation.op_type!=OperationType.READ_ROWS
153+
)
154+
encountered_exc=None
73155
try:
74156
asyncforresponseincall:
157+
# record time to first response. Currently only used for READ_ROWs
158+
ifnothas_first_response:
159+
operation.first_response_latency_ns= (
160+
time.monotonic_ns() -operation.start_time_ns
161+
)
162+
has_first_response=True
75163
yieldresponse
76164
exceptExceptionase:
77165
# handle errors while processing stream
78-
raisee
166+
encountered_exc=e
167+
raise
168+
finally:
169+
ifcallisnotNone:
170+
metadata=await_get_metadata(encountered_excorcall)
171+
ifmetadataisnotNone:
172+
operation.add_response_metadata(metadata)

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_helpers.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
fromgoogle.cloud.bigtable.data.read_rows_queryimportReadRowsQuery
2424

2525
fromgoogle.api_coreimportexceptionsascore_exceptions
26+
fromgoogle.api_core.retryimportexponential_sleep_generator
2627
fromgoogle.api_core.retryimportRetryFailureReason
2728
fromgoogle.cloud.bigtable.data.exceptionsimportRetryExceptionGroup
2829

@@ -248,3 +249,61 @@ def _get_retryable_errors(
248249
call_codes=table.default_mutate_rows_retryable_errors
249250

250251
return [_get_error_type(e) foreincall_codes]
252+
253+
254+
classTrackedBackoffGenerator:
255+
"""
256+
Generator class for exponential backoff sleep times.
257+
This implementation builds on top of api_core.retries.exponential_sleep_generator,
258+
adding the ability to retrieve previous values using get_attempt_backoff(idx).
259+
This is used by the Metrics class to track the sleep times used for each attempt.
260+
"""
261+
262+
def__init__(self, initial=0.01, maximum=60, multiplier=2):
263+
self.history= []
264+
self.subgenerator=exponential_sleep_generator(
265+
initial=initial, maximum=maximum, multiplier=multiplier
266+
)
267+
self._next_override: float|None=None
268+
269+
def__iter__(self):
270+
returnself
271+
272+
defset_next(self, next_value: float):
273+
"""
274+
Set the next backoff value, instead of generating one from subgenerator.
275+
After the value is yielded, it will go back to using self.subgenerator.
276+
277+
If set_next is called twice before the next() is called, only the latest
278+
value will be used and others discarded
279+
280+
Args:
281+
next_value: the upcomming value to yield when next() is called
282+
Raises:
283+
ValueError: if next_value is negative
284+
"""
285+
ifnext_value<0:
286+
raiseValueError("backoff value cannot be less than 0")
287+
self._next_override=next_value
288+
289+
def__next__(self) ->float:
290+
ifself._next_overrideisnotNone:
291+
next_backoff=self._next_override
292+
self._next_override=None
293+
else:
294+
next_backoff=next(self.subgenerator)
295+
self.history.append(next_backoff)
296+
returnnext_backoff
297+
298+
defget_attempt_backoff(self, attempt_idx) ->float:
299+
"""
300+
returns the backoff time for a specific attempt index, starting at 0.
301+
302+
Args:
303+
attempt_idx: the index of the attempt to return backoff for
304+
Raises:
305+
IndexError: if attempt_idx is negative, or not in history
306+
"""
307+
ifattempt_idx<0:
308+
raiseIndexError("received negative attempt number")
309+
returnself.history[attempt_idx]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2023 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+
fromgoogle.cloud.bigtable.data._metrics.metrics_controllerimport (
15+
BigtableClientSideMetricsController,
16+
)
17+
18+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportActiveOperationMetric
19+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportActiveAttemptMetric
20+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportCompletedOperationMetric
21+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportCompletedAttemptMetric
22+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportOperationState
23+
fromgoogle.cloud.bigtable.data._metrics.data_modelimportOperationType
24+
fromgoogle.cloud.bigtable.data._metrics.tracked_retryimporttracked_retry
25+
26+
__all__= (
27+
"BigtableClientSideMetricsController",
28+
"OperationType",
29+
"OperationState",
30+
"ActiveOperationMetric",
31+
"ActiveAttemptMetric",
32+
"CompletedOperationMetric",
33+
"CompletedAttemptMetric",
34+
"tracked_retry",
35+
)

0 commit comments

Comments
 (0)