2828from grpc import StatusCode
2929
3030import 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
3234if 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
3640ALLOW_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
4144DEFAULT_ZONE = "global"
4245DEFAULT_CLUSTER_ID = "unspecified"
4346
47+ # keys for parsing metadata blobs
4448BIGTABLE_METADATA_KEY = "x-goog-ext-425905942-bin"
4549SERVER_TIMING_METADATA_KEY = "server-timing"
46-
4750SERVER_TIMING_REGEX = re .compile (r"gfet4t7; dur=(\d+)" )
4851
4952INVALID_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 )
5556class 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 )
8390class 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 )
99107class 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
116125class 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
135149class 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
0 commit comments