2121import contextlib
2222import enum
2323import functools
24- from typing import Any , List , Tuple
24+ from typing import Any , List , Optional , Tuple
2525
2626from google .api_core import _observability , grpc_helpers
2727from google .api_core .gapic_v1 import client_info
@@ -117,87 +117,66 @@ def _extract_rpc_identity(
117117 Returns:
118118 Tuple[str, str, str]: A 3-tuple of (full_rpc_name, service_name, rpc_method_name).
119119 """
120- if isinstance (method_name , bytes ):
121- method_name = method_name .decode ("utf-8" )
122120 method_str = method_name .lstrip ("/" )
123121 service , _ , method = method_str .rpartition ("/" )
124122 return method_str , service , method
125123
126124
127- def _extract_status_code (exc : Exception ) -> str :
125+ def _extract_status_code (exc : Optional [ Exception ] ) -> str :
128126 """Extract canonical status code name string from an exception.
129127
130- Status code name strings are found in a variety of locations depending
131- on the status of the operation:
132- * RetryError (unwrapped to root cause)
133- * GoogleAPICallError (.grpc_status_code enum)
134- * Native gRPC exceptions (callable .code())
135- * Non-callable .code attributes (raw status code integers, stubs, mocks)
136- * Standard Python exceptions (fallback to class name)
128+ Status code name strings are resolved by inspecting the following locations:
129+ * Chained exceptions: Unwraps RetryError or __cause__ to the root exception.
130+ * Enum & code attributes: Inspects .grpc_status_code on GoogleAPICallError or .code on gRPC errors.
131+ * Integer status codes: Maps raw gRPC integer status codes to canonical enum names.
132+ * Fallback: Defaults to the exception class name for standard Python errors.
137133
138134 Args:
139- exc (Exception): The exception to extract the status code name from.
135+ exc (Optional[ Exception] ): The exception to extract the status code name from.
140136
141137 Returns:
142- str: The canonical status code name (e.g. "NOT_FOUND", "UNAVAILABLE").
138+ str: The canonical status code name (e.g. "NOT_FOUND", "UNAVAILABLE") or class name .
143139 """
144140 if exc is None :
145141 return ""
146142
147- # 1. Unwrap Retry/Transport wrappers and chained exceptions
148- # api_core's RetryError wraps the root failure in .cause, and standard Python chaining uses .__cause__
149- target_exc = getattr (exc , "cause" , None ) or getattr (exc , "__cause__" , None ) or exc
143+ # 1. Unwrap chained exceptions: unwrap RetryError or __cause__ to the root failure
144+ target = getattr (exc , "cause" , None ) or getattr (exc , "__cause__" , None ) or exc
150145
151- # 2. Check GoogleAPICallError subclasses
152- # api_core exceptions (NotFound, InternalServerError, etc.) define a .grpc_status_code enum
153- grpc_status = getattr (target_exc , "grpc_status_code" , None )
154- if grpc_status is not None :
155- name = getattr (grpc_status , "name" , None )
156- if name is not None :
157- return str (name )
158-
159- # 3. Check native gRPC exceptions (grpc.RpcError / grpc.Call)
160- # Native gRPC error instances expose a callable .code() method returning a grpc.StatusCode enum
161- code_fn = getattr (target_exc , "code" , None )
162- if callable (code_fn ):
146+ # 2. Check enum & code attributes: .grpc_status_code enum or callable/non-callable .code
147+ status = getattr (target , "grpc_status_code" , None )
148+ if status is None and hasattr (target , "code" ):
163149 try :
164- code_val = code_fn ()
165- name = getattr (code_val , "name" , None )
166- if name is not None :
167- return str (name )
150+ status = target .code () if callable (target .code ) else target .code
168151 except Exception :
169- pass
152+ status = None
153+
154+ name = getattr (status , "name" , None )
155+ if name :
156+ return str (name )
170157
171- # 4. Check non-callable .code attributes (e.g. raw status code integers, stubs, mocks)
172- elif code_fn is not None :
173- name = getattr (code_fn , "name" , None )
174- if name is not None :
175- return str (name )
176- # If code is an integer (e.g. HTTP status or gRPC integer), map to canonical enum name
177- if isinstance (code_fn , int ):
178- from google .api_core import exceptions
158+ # 3. Check integer status codes: map raw gRPC integer status codes to canonical enum names
159+ if isinstance (status , int ):
160+ from google .api_core import exceptions
179161
180- if code_fn in exceptions ._INT_TO_GRPC_CODE :
181- return str (exceptions ._INT_TO_GRPC_CODE [code_fn ].name )
182- return str (code_fn )
162+ status = exceptions ._INT_TO_GRPC_CODE .get (status , status )
163+ return getattr (status , "name" , str (status ))
183164
184- # 5. Standard Python exception fallback
185- # For ValueError, RuntimeError, etc., fall back to class name per OpenTelemetry conventions
186- return target_exc .__class__ .__name__
165+ # 4. Fallback: default to the exception class name for standard Python errors
166+ return target .__class__ .__name__
187167
188168
189- def _extract_error_attributes (exc : Exception ) -> dict [str , Any ]:
169+ def _extract_error_attributes (exc : Optional [ Exception ] ) -> dict [str , Any ]:
190170 """Extract gcp.errors.* and error.type attributes from an exception.
191171
192- Error details and ErrorInfo structures are found in a variety of locations
193- depending on the status of the operation:
194- * RetryError (unwrapped to root cause)
195- * GoogleAPICallError (_error_info or error_info attribute)
196- * Native gRPC exceptions (parsed from trailing_metadata)
197- * Direct exception attributes (domain, reason, metadata fallbacks)
172+ Error details and ErrorInfo structures are resolved by inspecting the following locations:
173+ * Chained exceptions: Unwraps RetryError or __cause__ to the root exception.
174+ * GoogleAPICallError attributes: Reads ErrorInfo from ._error_info or .error_info.
175+ * Native gRPC trailing metadata: Parses google.rpc.Status binary details from trailing_metadata.
176+ * Unified attribute extraction: Extracts domain, reason, and metadata from ErrorInfo or exception attributes.
198177
199178 Args:
200- exc (Exception): An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object.
179+ exc (Optional[ Exception] ): An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object.
201180
202181 Returns:
203182 dict[str, Any]: Extracted error attributes (e.g. gcp.errors.domain, error.type, gcp.errors.metadata.*).
@@ -206,18 +185,15 @@ def _extract_error_attributes(exc: Exception) -> dict[str, Any]:
206185 if exc is None :
207186 return attrs
208187
209- # 1. Unwrap Retry/Transport wrappers and chained exceptions
210- # api_core's RetryError wraps the root failure in .cause, and standard Python chaining uses .__cause__
188+ # 1. Unwrap chained exceptions: unwrap RetryError or __cause__ to the root failure
211189 target_exc = getattr (exc , "cause" , None ) or getattr (exc , "__cause__" , None ) or exc
212190
213191 # 2. Check GoogleAPICallError ErrorInfo attributes
214- # Subclasses of GoogleAPICallError store google.rpc.ErrorInfo under ._error_info or .error_info
215192 error_info = getattr (target_exc , "_error_info" , None ) or getattr (
216193 target_exc , "error_info" , None
217194 )
218195
219- # 3. Check native gRPC exceptions (parsed from trailing_metadata)
220- # Native gRPC errors or responses carry trailing_metadata containing binary google.rpc.Status details
196+ # 3. Check native gRPC trailing metadata for binary google.rpc.Status details
221197 if error_info is None :
222198 rpc_call = (
223199 target_exc
@@ -232,33 +208,18 @@ def _extract_error_attributes(exc: Exception) -> dict[str, Any]:
232208 except Exception :
233209 pass
234210
235- # 4. Extract attributes from ErrorInfo payload
236- # Extracts gcp.errors.domain, error.type (from reason), and gcp.errors.metadata.<key>
237- if error_info is not None :
238- domain = getattr (error_info , "domain" , None )
239- if domain and isinstance (domain , str ):
240- attrs ["gcp.errors.domain" ] = domain
241- reason = getattr (error_info , "reason" , None )
242- if reason and isinstance (reason , str ):
243- attrs ["error.type" ] = reason
244- metadata = getattr (error_info , "metadata" , None )
245- if metadata and hasattr (metadata , "items" ):
246- for k , v in metadata .items ():
247- attrs [f"gcp.errors.metadata.{ k } " ] = str (v )
248-
249- # 5. Direct exception attribute fallback
250- # Some custom error classes or REST errors define domain, reason, or metadata directly on the exception
251- else :
252- domain = getattr (target_exc , "domain" , None )
253- if domain and isinstance (domain , str ):
254- attrs ["gcp.errors.domain" ] = domain
255- reason = getattr (target_exc , "reason" , None )
256- if reason and isinstance (reason , str ):
257- attrs ["error.type" ] = reason
258- metadata = getattr (target_exc , "metadata" , None )
259- if metadata and hasattr (metadata , "items" ):
260- for k , v in metadata .items ():
261- attrs [f"gcp.errors.metadata.{ k } " ] = str (v )
211+ # 4. Unified attribute extraction: extract domain, reason, and metadata from ErrorInfo or exception attributes
212+ source = error_info or target_exc
213+ domain = getattr (source , "domain" , None )
214+ if domain and isinstance (domain , str ):
215+ attrs ["gcp.errors.domain" ] = domain
216+ reason = getattr (source , "reason" , None )
217+ if reason and isinstance (reason , str ):
218+ attrs ["error.type" ] = reason
219+ metadata = getattr (source , "metadata" , None )
220+ if metadata and hasattr (metadata , "items" ):
221+ for k , v in metadata .items ():
222+ attrs [f"gcp.errors.metadata.{ k } " ] = str (v )
262223
263224 return attrs
264225
@@ -313,10 +274,6 @@ def __init__(
313274 self ._retry = retry
314275 self ._timeout = timeout
315276 self ._compression = compression
316- self ._client_options = client_options
317- self ._method_name = method_name
318- self ._is_streaming = is_streaming
319- self ._kind = kind
320277
321278 # Pre-extract the x-goog-api-client header from the initialized metadata.
322279 self ._x_goog_api_client , remaining = _extract_metrics_header (metadata )
@@ -329,14 +286,12 @@ def __init__(
329286 else :
330287 self ._default_metadata = self ._static_metadata
331288
332- # Resolve and cache the OpenTelemetry tracer and attributes once at initialization.
333- # For now, tracing is gated to non-streaming gRPC calls where an explicit method_name is provided.
334- self ._tracer = None
335- self ._span_name = None
336- self ._span_attributes = None
289+ # Configure the OpenTelemetry span factory once at initialization.
290+ # For now, method tracing is gated to non-streaming gRPC calls where an explicit method_name is provided.
291+ self ._start_span_fn = None
337292 if (
338293 not is_streaming
339- and kind in ( "grpc" , "grpc_asyncio" )
294+ and kind == "grpc"
340295 and method_name is not None
341296 and _observability .is_otel_capabilities_enabled (client_options )
342297 ):
@@ -349,20 +304,24 @@ def __init__(
349304 else None
350305 )
351306 if tracer_provider is not None :
352- self . _tracer = tracer_provider .get_tracer ("google.api_core" )
307+ tracer = tracer_provider .get_tracer ("google.api_core" )
353308 else :
354- self . _tracer = trace .get_tracer ("google.api_core" )
309+ tracer = trace .get_tracer ("google.api_core" )
355310
356- self . _span_name , _ , _ = _extract_rpc_identity (method_name )
357- self . _span_attributes = {
311+ span_name , _ , _ = _extract_rpc_identity (method_name )
312+ span_attributes = {
358313 "rpc.system.name" : "grpc" ,
359- "rpc.method" : self . _span_name ,
314+ "rpc.method" : span_name ,
360315 }
316+ self ._start_span_fn = functools .partial (
317+ tracer .start_as_current_span ,
318+ span_name ,
319+ kind = trace .SpanKind .CLIENT ,
320+ attributes = span_attributes ,
321+ )
361322 except (ImportError , AttributeError , TypeError ):
362323 # Gracefully disable tracing if OpenTelemetry or custom provider fails
363- self ._tracer = None
364- self ._span_name = None
365- self ._span_attributes = None
324+ self ._start_span_fn = None
366325
367326 def __call__ (
368327 self , * args , timeout = DEFAULT , retry = DEFAULT , compression = DEFAULT , ** kwargs
@@ -402,39 +361,26 @@ def __call__(
402361 if self ._compression is not None :
403362 kwargs ["compression" ] = compression
404363
405- span_context_manager = contextlib .nullcontext ()
406- if self ._tracer is not None and self . _span_name is not None :
364+ span_cm = contextlib .nullcontext ()
365+ if self ._start_span_fn is not None :
407366 try :
408- from opentelemetry import trace
409-
410- span_context_manager = self ._tracer .start_as_current_span (
411- self ._span_name ,
412- kind = trace .SpanKind .CLIENT ,
413- attributes = self ._span_attributes ,
414- )
367+ span_cm = self ._start_span_fn ()
415368 except Exception :
416- # Purposefully and gracefully bypass OpenTelemetry errors to ensure RPC success.
417- span_context_manager = contextlib .nullcontext ()
369+ span_cm = contextlib .nullcontext ()
418370
419- with span_context_manager as span :
371+ with span_cm as span :
420372 try :
421373 result = wrapped_func (* args , ** kwargs )
422374 if span is not None and hasattr (span , "set_attribute" ):
423375 span .set_attribute ("rpc.response.status_code" , "OK" )
424376 return result
425377 except Exception as exc :
426- if span is not None :
427- if hasattr (span , "record_exception" ):
428- from opentelemetry import trace
429-
430- span .record_exception (exc )
431- span .set_status (trace .StatusCode .ERROR , str (exc ))
432- if hasattr (span , "set_attribute" ):
433- span .set_attribute (
434- "rpc.response.status_code" , _extract_status_code (exc )
435- )
436- for k , v in _extract_error_attributes (exc ).items ():
437- span .set_attribute (k , v )
378+ if span is not None and hasattr (span , "set_attribute" ):
379+ span .set_attribute (
380+ "rpc.response.status_code" , _extract_status_code (exc )
381+ )
382+ for k , v in _extract_error_attributes (exc ).items ():
383+ span .set_attribute (k , v )
438384 raise
439385
440386
0 commit comments