Skip to content

Commit 099e8df

Browse files
authored
refactor(gapic): streamline method span creation and defer async tracing
- Use functools.partial for _start_span_fn, eliminating redundant instance attributes on _GapicCallable - Let OpenTelemetry context manager automatically record escaping exceptions to prevent duplicates - Streamline _extract_status_code with parallel docstrings/comments and unify _extract_error_attributes - Remove unnecessary runtime type checks on strictly typed method_name - Defer async method tracing to follow-up PR, restoring method_async.py to main
1 parent 9c2dc99 commit 099e8df

5 files changed

Lines changed: 94 additions & 295 deletions

File tree

packages/google-api-core/google/api_core/gapic_v1/method.py

Lines changed: 75 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import contextlib
2222
import enum
2323
import functools
24-
from typing import Any, List, Tuple
24+
from typing import Any, List, Optional, Tuple
2525

2626
from google.api_core import _observability, grpc_helpers
2727
from 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

packages/google-api-core/google/api_core/gapic_v1/method_async.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@ def wrap_method(
3737
default_compression=None,
3838
client_info=client_info.DEFAULT_CLIENT_INFO,
3939
kind=_DEFAULT_ASYNC_TRANSPORT_KIND,
40-
*,
41-
client_options=None,
42-
method_name=None,
43-
is_streaming=False,
4440
):
4541
"""Wrap an async RPC method with common behavior.
4642
@@ -61,10 +57,5 @@ def wrap_method(
6157
default_timeout,
6258
default_compression,
6359
metadata=metadata,
64-
client_options=client_options,
65-
method_name=method_name,
66-
is_streaming=is_streaming,
67-
client_info=client_info,
68-
kind=kind,
6960
)
7061
)

0 commit comments

Comments
 (0)