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

Commit b38b1b5

Browse files
committed
Merge branch 'client_side_metrics_data_model' into client_side_metrics_handlers
2 parents a15a1bc + 24432c8 commit b38b1b5

7 files changed

Lines changed: 299 additions & 178 deletions

File tree

google/cloud/bigtable/data/_helpers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from google.cloud.bigtable.data.read_rows_query import ReadRowsQuery
2424

2525
from google.api_core import exceptions as core_exceptions
26+
from google.api_core.retry import exponential_sleep_generator
2627
from google.api_core.retry import RetryFailureReason
2728
from google.cloud.bigtable.data.exceptions import RetryExceptionGroup
2829

@@ -97,6 +98,25 @@ def _attempt_timeout_generator(
9798
yield max(0, min(per_request_timeout, deadline - time.monotonic()))
9899

99100

101+
def backoff_generator(initial=0.01, multiplier=2, maximum=60):
102+
"""
103+
Build a generator for exponential backoff sleep times.
104+
105+
This implementation builds on top of api_core.retries.exponential_sleep_generator,
106+
adding the ability to retrieve previous values using the send(idx) method. This is
107+
used by the Metrics class to track the sleep times used for each attempt.
108+
"""
109+
history = []
110+
subgenerator = exponential_sleep_generator(initial, multiplier, maximum)
111+
while True:
112+
next_backoff = next(subgenerator)
113+
history.append(next_backoff)
114+
sent_idx = yield next_backoff
115+
while sent_idx is not None:
116+
# requesting from history
117+
sent_idx = yield history[sent_idx]
118+
119+
100120
def _retry_exception_factory(
101121
exc_list: list[Exception],
102122
reason: RetryFailureReason,

google/cloud/bigtable/data/_metrics/data_model.py

Lines changed: 82 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -28,31 +28,38 @@
2828
from grpc import StatusCode
2929

3030
import google.cloud.bigtable.data.exceptions as bt_exceptions
31+
from google.cloud.bigtable_v2.types.response_params import ResponseParams
32+
from google.protobuf.message import DecodeError
3133

3234
if TYPE_CHECKING:
3335
from google.cloud.bigtable.data._metrics.handlers._base import MetricsHandler
3436

3537

38+
# by default, exceptions in the metrics system are logged,
39+
# but enabling this flag causes them to be raised instead
3640
ALLOW_METRIC_EXCEPTIONS = os.getenv("BIGTABLE_METRICS_EXCEPTIONS", False)
37-
LOGGER = (
38-
logging.getLogger(__name__) if os.getenv("BIGTABLE_METRICS_LOGS", False) else None
39-
)
41+
LOGGER = logging.getLogger(__name__)
4042

43+
# default values for zone and cluster data, if not captured
4144
DEFAULT_ZONE = "global"
4245
DEFAULT_CLUSTER_ID = "unspecified"
4346

47+
# keys for parsing metadata blobs
4448
BIGTABLE_METADATA_KEY = "x-goog-ext-425905942-bin"
4549
SERVER_TIMING_METADATA_KEY = "server-timing"
46-
4750
SERVER_TIMING_REGEX = re.compile(r"gfet4t7; dur=(\d+)")
4851

4952
INVALID_STATE_ERROR = "Invalid state for {}: {}"
5053

5154

52-
# create a named tuple that holds the clock time, and a more accurate monotonic timestamp
53-
# this allows us to be resistent to clock changes, eg DST
5455
@dataclass(frozen=True)
5556
class TimeTuple:
57+
"""
58+
Tuple that holds both the utc timestamp to record with the metrics, and a
59+
monotonic timestamp for calculating durations. The monotonic timestamp is
60+
preferred for calculations because it is resilient to clock changes, eg DST
61+
"""
62+
5663
utc: datetime.datetime = field(
5764
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
5865
)
@@ -82,60 +89,67 @@ class OperationState(Enum):
8289
@dataclass(frozen=True)
8390
class CompletedAttemptMetric:
8491
"""
85-
A dataclass representing the data associated with a completed rpc attempt.
92+
An immutable dataclass representing the data associated with a
93+
completed rpc attempt.
8694
"""
8795

8896
start_time: datetime.datetime
89-
duration: float
97+
duration_ms: float
9098
end_status: StatusCode
91-
first_response_latency: float | None = None
92-
gfe_latency: float | None = None
93-
application_blocking_time: float = 0.0
94-
backoff_before_attempt: float = 0.0
95-
grpc_throttling_time: float = 0.0
99+
first_response_latency_ms: float | None = None
100+
gfe_latency_ms: float | None = None
101+
application_blocking_time_ms: float = 0.0
102+
backoff_before_attempt_ms: float = 0.0
103+
grpc_throttling_time_ms: float = 0.0
96104

97105

98106
@dataclass(frozen=True)
99107
class CompletedOperationMetric:
100108
"""
101-
A dataclass representing the data associated with a completed rpc operation.
109+
An immutable dataclass representing the data associated with a
110+
completed rpc operation.
102111
"""
103112

104113
op_type: OperationType
105114
start_time: datetime.datetime
106-
duration: float
115+
duration_ms: float
107116
completed_attempts: list[CompletedAttemptMetric]
108117
final_status: StatusCode
109118
cluster_id: str
110119
zone: str
111120
is_streaming: bool
112-
flow_throttling_time: float = 0.0
121+
flow_throttling_time_ms: float = 0.0
113122

114123

115124
@dataclass
116125
class ActiveAttemptMetric:
126+
"""
127+
A dataclass representing the data associated with an rpc attempt that is
128+
currently in progress. Fields are mutable and may be optional.
129+
"""
130+
117131
# keep both clock time and monotonic timestamps for active attempts
118132
start_time: TimeTuple = field(default_factory=TimeTuple)
119-
# the time it takes to recieve the first response from the server
133+
# the time it takes to recieve the first response from the server, in milliseconds
120134
# currently only tracked for ReadRows
121-
first_response_latency: float | None = None
122-
# the time taken by the backend. Taken from response header
123-
gfe_latency: float | None = None
124-
# time waiting on user to process the response
135+
first_response_latency_ms: float | None = None
136+
# the time taken by the backend, in milliseconds. Taken from response header
137+
gfe_latency_ms: float | None = None
138+
# time waiting on user to process the response, in milliseconds
125139
# currently only relevant for ReadRows
126-
application_blocking_time: float = 0.0
127-
# backoff time is added to application_blocking_time
128-
backoff_before_attempt: float = 0.0
129-
# time waiting on grpc channel
140+
application_blocking_time_ms: float = 0.0
141+
# backoff time is added to application_blocking_time_ms
142+
backoff_before_attempt_ms: float = 0.0
143+
# time waiting on grpc channel, in milliseconds
130144
# TODO: capture grpc_throttling_time
131-
grpc_throttling_time: float = 0.0
145+
grpc_throttling_time_ms: float = 0.0
132146

133147

134148
@dataclass
135149
class ActiveOperationMetric:
136150
"""
137151
A dataclass representing the data associated with an rpc operation that is
138-
currently in progress.
152+
currently in progress. Fields are mutable and may be optional.
139153
"""
140154

141155
op_type: OperationType
@@ -149,8 +163,8 @@ class ActiveOperationMetric:
149163
is_streaming: bool = False # only True for read_rows operations
150164
was_completed: bool = False
151165
handlers: list[MetricsHandler] = field(default_factory=list)
152-
# time waiting on flow control
153-
flow_throttling_time: float = 0.0
166+
# time waiting on flow control, in milliseconds
167+
flow_throttling_time_ms: float = 0.0
154168

155169
@property
156170
def state(self) -> OperationState:
@@ -194,11 +208,14 @@ def start_attempt(self) -> None:
194208
# find backoff value
195209
if self.backoff_generator and len(self.completed_attempts) > 0:
196210
# find the attempt's backoff by sending attempt number to generator
197-
backoff = self.backoff_generator.send(len(self.completed_attempts) - 1)
211+
# generator will return the backoff time in seconds, so convert to ms
212+
backoff_ms = (
213+
self.backoff_generator.send(len(self.completed_attempts) - 1) * 1000
214+
)
198215
else:
199-
backoff = 0
216+
backoff_ms = 0
200217

201-
self.active_attempt = ActiveAttemptMetric(backoff_before_attempt=backoff)
218+
self.active_attempt = ActiveAttemptMetric(backoff_before_attempt_ms=backoff_ms)
202219

203220
def add_response_metadata(self, metadata: dict[str, bytes | str]) -> None:
204221
"""
@@ -217,12 +234,16 @@ def add_response_metadata(self, metadata: dict[str, bytes | str]) -> None:
217234
INVALID_STATE_ERROR.format("add_response_metadata", self.state)
218235
)
219236
if self.cluster_id is None or self.zone is None:
220-
# BIGTABLE_METADATA_KEY should give a binary string with cluster_id and zone
237+
# BIGTABLE_METADATA_KEY should give a binary-encoded ResponseParams proto
221238
blob = cast(bytes, metadata.get(BIGTABLE_METADATA_KEY))
222239
if blob:
223240
parse_result = self._parse_response_metadata_blob(blob)
224241
if parse_result is not None:
225-
self.zone, self.cluster_id = parse_result
242+
cluster, zone = parse_result
243+
if cluster:
244+
self.cluster_id = cluster
245+
if zone:
246+
self.zone = zone
226247
else:
227248
self._handle_error(
228249
f"Failed to decode {BIGTABLE_METADATA_KEY} metadata: {blob!r}"
@@ -232,31 +253,25 @@ def add_response_metadata(self, metadata: dict[str, bytes | str]) -> None:
232253
if timing_header:
233254
timing_data = SERVER_TIMING_REGEX.match(timing_header)
234255
if timing_data and self.active_attempt:
235-
# convert from milliseconds to seconds
236-
self.active_attempt.gfe_latency = float(timing_data.group(1)) / 1000
256+
self.active_attempt.gfe_latency_ms = float(timing_data.group(1))
237257

238258
@staticmethod
239259
@lru_cache(maxsize=32)
240260
def _parse_response_metadata_blob(blob: bytes) -> Tuple[str, str] | None:
241261
"""
242-
Parse the response metadata blob and return a dictionary of key-value pairs.
262+
Parse the response metadata blob and return a tuple of cluster and zone.
243263
244264
Function is cached to avoid parsing the same blob multiple times.
245265
246266
Args:
247267
- blob: the metadata blob as extracted from the grpc call
248268
Returns:
249-
- a tuple of zone and cluster_id, or None if parsing failed
269+
- a tuple of cluster_id and zone, or None if parsing failed
250270
"""
251271
try:
252-
decoded = "".join(
253-
c if c.isprintable() else " " for c in blob.decode("utf-8")
254-
)
255-
split_data = decoded.split()
256-
zone = split_data[0]
257-
cluster_id = split_data[1]
258-
return zone, cluster_id
259-
except (AttributeError, IndexError):
272+
proto = ResponseParams.pb().FromString(blob)
273+
return proto.cluster_id, proto.zone_id
274+
except (DecodeError, TypeError):
260275
# failed to parse metadata
261276
return None
262277

@@ -273,11 +288,12 @@ def attempt_first_response(self) -> None:
273288
return self._handle_error(
274289
INVALID_STATE_ERROR.format("attempt_first_response", self.state)
275290
)
276-
if self.active_attempt.first_response_latency is not None:
291+
if self.active_attempt.first_response_latency_ms is not None:
277292
return self._handle_error("Attempt already received first response")
278-
self.active_attempt.first_response_latency = (
293+
# convert duration to milliseconds
294+
self.active_attempt.first_response_latency_ms = (
279295
time.monotonic() - self.active_attempt.start_time.monotonic
280-
)
296+
) * 1000
281297

282298
def end_attempt_with_status(self, status: StatusCode | Exception) -> None:
283299
"""
@@ -293,18 +309,18 @@ def end_attempt_with_status(self, status: StatusCode | Exception) -> None:
293309
return self._handle_error(
294310
INVALID_STATE_ERROR.format("end_attempt_with_status", self.state)
295311
)
296-
312+
if isinstance(status, Exception):
313+
status = self._exc_to_status(status)
314+
duration_seconds = time.monotonic() - self.active_attempt.start_time.monotonic
297315
new_attempt = CompletedAttemptMetric(
298316
start_time=self.active_attempt.start_time.utc,
299-
first_response_latency=self.active_attempt.first_response_latency,
300-
duration=time.monotonic() - self.active_attempt.start_time.monotonic,
301-
end_status=self._exc_to_status(status)
302-
if isinstance(status, Exception)
303-
else status,
304-
gfe_latency=self.active_attempt.gfe_latency,
305-
application_blocking_time=self.active_attempt.application_blocking_time,
306-
backoff_before_attempt=self.active_attempt.backoff_before_attempt,
307-
grpc_throttling_time=self.active_attempt.grpc_throttling_time,
317+
first_response_latency_ms=self.active_attempt.first_response_latency_ms,
318+
duration_ms=duration_seconds * 1000,
319+
end_status=status,
320+
gfe_latency_ms=self.active_attempt.gfe_latency_ms,
321+
application_blocking_time_ms=self.active_attempt.application_blocking_time_ms,
322+
backoff_before_attempt_ms=self.active_attempt.backoff_before_attempt_ms,
323+
grpc_throttling_time_ms=self.active_attempt.grpc_throttling_time_ms,
308324
)
309325
self.completed_attempts.append(new_attempt)
310326
self.active_attempt = None
@@ -338,12 +354,12 @@ def end_with_status(self, status: StatusCode | Exception) -> None:
338354
op_type=self.op_type,
339355
start_time=self.start_time.utc,
340356
completed_attempts=self.completed_attempts,
341-
duration=time.monotonic() - self.start_time.monotonic,
357+
duration_ms=(time.monotonic() - self.start_time.monotonic) * 1000,
342358
final_status=final_status,
343359
cluster_id=self.cluster_id or DEFAULT_CLUSTER_ID,
344360
zone=self.zone or DEFAULT_ZONE,
345361
is_streaming=self.is_streaming,
346-
flow_throttling_time=self.flow_throttling_time,
362+
flow_throttling_time_ms=self.flow_throttling_time_ms,
347363
)
348364
for handler in self.handlers:
349365
handler.on_operation_complete(finalized)
@@ -417,12 +433,15 @@ def _handle_error(message: str) -> None:
417433
full_message = f"Error in Bigtable Metrics: {message}"
418434
if ALLOW_METRIC_EXCEPTIONS:
419435
raise ValueError(full_message)
420-
if LOGGER:
421-
LOGGER.warning(full_message)
436+
LOGGER.warning(full_message)
422437

423438
async def __aenter__(self):
424439
"""
425440
Implements the async context manager protocol for wrapping unary calls
441+
442+
Using the operation's context manager provides assurances that the operation
443+
is always closed when complete, with the proper status code automaticallty
444+
detected when an exception is raised.
426445
"""
427446
return self._AsyncContextManager(self)
428447

google/cloud/bigtable/data/_metrics/handlers/opentelemetry.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def on_operation_complete(self, op: CompletedOperationMetric) -> None:
175175
is_streaming = str(op.is_streaming)
176176

177177
self.otel.operation_latencies.record(
178-
op.duration, {"streaming": is_streaming, **labels}
178+
op.duration_ms, {"streaming": is_streaming, **labels}
179179
)
180180
# only record completed attempts if there were retries
181181
if op.completed_attempts:
@@ -203,26 +203,26 @@ def on_attempt_complete(
203203
is_streaming = str(op.is_streaming)
204204

205205
self.otel.attempt_latencies.record(
206-
attempt.duration, {"streaming": is_streaming, "status": status, **labels}
206+
attempt.duration_ms, {"streaming": is_streaming, "status": status, **labels}
207207
)
208-
combined_throttling = attempt.grpc_throttling_time
208+
combined_throttling = attempt.grpc_throttling_time_ms
209209
if not op.completed_attempts:
210210
# add flow control latency to first attempt's throttling latency
211-
combined_throttling += op.flow_throttling_time
211+
combined_throttling += op.flow_throttling_time_ms
212212
self.otel.throttling_latencies.record(combined_throttling, labels)
213213
self.otel.application_latencies.record(
214-
attempt.application_blocking_time + attempt.backoff_before_attempt, labels
214+
attempt.application_blocking_time_ms + attempt.backoff_before_attempt_ms, labels
215215
)
216216
if (
217217
op.op_type == OperationType.READ_ROWS
218-
and attempt.first_response_latency is not None
218+
and attempt.first_response_latency_ms is not None
219219
):
220220
self.otel.first_response_latencies.record(
221-
attempt.first_response_latency, {"status": status, **labels}
221+
attempt.first_response_latency_ms, {"status": status, **labels}
222222
)
223-
if attempt.gfe_latency is not None:
223+
if attempt.gfe_latency_ms is not None:
224224
self.otel.server_latencies.record(
225-
attempt.gfe_latency,
225+
attempt.gfe_latency_ms,
226226
{"streaming": is_streaming, "status": status, **labels},
227227
)
228228
else:

noxfile.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ def format(session):
133133
def mypy(session):
134134
"""Verify type hints are mypy compatible."""
135135
session.install("-e", ".")
136-
session.install("-e", "python-api-core")
137136
session.install(
138137
"mypy",
139138
"types-setuptools",

0 commit comments

Comments
 (0)