Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/google/api_core/_observability.py: 19%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

339 statements  

1# -*- coding: utf-8 -*- 

2# Copyright 2026 Google LLC 

3# 

4# Licensed under the Apache License, Version 2.0 (the "License"); 

5# you may not use this file except in compliance with the License. 

6# You may obtain a copy of the License at 

7# 

8# http://www.apache.org/licenses/LICENSE-2.0 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

13# See the License for the specific language governing permissions and 

14# limitations under the License. 

15# 

16 

17"""OpenTelemetry helpers for resolving and instantiating interceptors.""" 

18 

19from __future__ import annotations 

20 

21import contextlib 

22import urllib.parse 

23from typing import TYPE_CHECKING, Any, Callable, Sequence 

24 

25from google.api_core import _feature_gating_helpers 

26from google.api_core.client_options import ClientOptions 

27 

28try: 

29 from opentelemetry.instrumentation.utils import ( 

30 suppress_instrumentation as _suppress_instrumentation, 

31 ) 

32except ImportError: 

33 _suppress_instrumentation = contextlib.nullcontext 

34 

35if TYPE_CHECKING: 

36 # flake8: grpc, trace, and ClientInterceptor are imported only for static analysis and type annotations 

37 # The 'noqa: F401' comment avoids flake8 "imported but not used" errors. 

38 import grpc # noqa: F401 

39 import opentelemetry.trace # noqa: F401 

40 

41 from google.api_core.grpc_helpers import ClientInterceptor # noqa: F401 

42 

43_TRACER_PROVIDER = "tracer_provider" 

44 

45 

46def is_otel_capabilities_enabled( 

47 client_options: ClientOptions | dict[str, Any] | None = None, 

48 env_var: str = "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", 

49) -> bool: 

50 """Checks if OTel capabilities are enabled and installed. 

51 

52 Args: 

53 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options 

54 object or dictionary. 

55 env_var (str): The environment variable to check for enablement. 

56 

57 Returns: 

58 bool: True if enabled and installed, False otherwise. 

59 """ 

60 is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( 

61 env_var=env_var, 

62 feature_key=_TRACER_PROVIDER, 

63 configuration=client_options, 

64 ) 

65 

66 if is_tracing_enabled: 

67 try: 

68 import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] # noqa: F401 

69 

70 return True 

71 except ImportError: 

72 pass 

73 

74 return False 

75 

76 

77def _extract_endpoint_attributes( 

78 client_options: ClientOptions | dict[str, Any] | None = None, 

79 default_host: str | None = None, 

80) -> dict[str, Any]: 

81 """Extracts server.address, server.port (if non-default), and url.domain from client options if present. 

82 

83 Args: 

84 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options 

85 object or dictionary. 

86 default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not 

87 set on ``client_options``. 

88 

89 Returns: 

90 dict[str, Any]: A dictionary containing url.domain and, if an api_endpoint or default_host 

91 is configured, server.address and non-default server.port. 

92 """ 

93 attrs: dict[str, Any] = {} 

94 endpoint = None 

95 universe_domain = None 

96 

97 if isinstance(client_options, dict): 

98 endpoint = client_options.get("api_endpoint") or default_host 

99 universe_domain = client_options.get("universe_domain") 

100 elif client_options is not None: 

101 endpoint = getattr(client_options, "api_endpoint", None) or default_host 

102 universe_domain = getattr(client_options, "universe_domain", None) 

103 else: 

104 endpoint = default_host 

105 

106 attrs["url.domain"] = universe_domain or "googleapis.com" 

107 

108 if endpoint and isinstance(endpoint, str): 

109 target = endpoint if "//" in endpoint else f"//{endpoint}" 

110 parsed = None 

111 hostname = None 

112 port = None 

113 try: 

114 parsed = urllib.parse.urlsplit(target) 

115 hostname = parsed.hostname 

116 port = parsed.port 

117 except ValueError: 

118 pass 

119 

120 if hostname: 

121 attrs["server.address"] = hostname 

122 if port and parsed: 

123 scheme = parsed.scheme.lower() 

124 is_default_port = (port == 443 and scheme in ("https", "")) or ( 

125 port == 80 and scheme == "http" 

126 ) 

127 if not is_default_port: 

128 attrs["server.port"] = port 

129 return attrs 

130 

131 

132def _make_grpc_client_request_hook( 

133 endpoint_attrs: dict[str, Any] | None = None, 

134) -> Callable[[Any, Any], None]: 

135 """Creates an OpenTelemetry gRPC client request hook with optional endpoint attributes. 

136 

137 Args: 

138 endpoint_attrs (Optional[dict[str, Any]]): Optional static endpoint attributes to attach 

139 to every span. 

140 

141 Returns: 

142 Callable[[Any, Any], None]: The request hook callback. 

143 """ 

144 static_attrs = dict(endpoint_attrs) if endpoint_attrs else {} 

145 

146 def client_request_hook(span: Any, request: Any) -> None: 

147 if span is None or not getattr(span, "is_recording", lambda: True)(): 

148 return 

149 

150 # Upstream opentelemetry-instrumentation-grpc may format span names with a 

151 # leading slash (e.g. "/package.Service/Method"). Normalize the span name 

152 # and ensure rpc.method is always captured as the clean, fully-qualified name. 

153 span_name = getattr(span, "name", None) 

154 if isinstance(span_name, str) and span_name: 

155 clean_method_name = span_name.lstrip("/") 

156 if span_name.startswith("/") and hasattr(span, "update_name"): 

157 span.update_name(clean_method_name) 

158 span.set_attribute("rpc.method", clean_method_name) 

159 

160 attrs: dict[str, Any] = { 

161 "rpc.system.name": "grpc", 

162 } 

163 if static_attrs: 

164 attrs.update(static_attrs) 

165 for key, value in attrs.items(): 

166 span.set_attribute(key, value) 

167 

168 return client_request_hook 

169 

170 

171_grpc_client_request_hook = _make_grpc_client_request_hook() 

172 

173 

174def _grpc_client_response_hook(span: Any, response: Any) -> None: 

175 """OpenTelemetry gRPC client response hook to record successful response status. 

176 

177 Upstream ``opentelemetry-instrumentation-grpc`` sets the integer status code 

178 ``rpc.grpc.status_code`` (e.g. 0), but does not record the modern string status 

179 ``rpc.response.status_code`` (e.g. "OK") required by Cloud Trace and current 

180 OpenTelemetry semantic conventions (v1.27.0+). 

181 

182 This hook enriches successful RPC attempt spans with ``rpc.response.status_code = "OK"``. 

183 Errors and non-OK statuses are handled at the Tier 3 method span layer or upstream. 

184 

185 Upstream handles synchronous and asynchronous invocations differently: 

186 - **Synchronous gRPC**: Upstream only invokes the response hook when an RPC call 

187 succeeds. On failure, the hook is bypassed entirely. 

188 - **Asynchronous gRPC**: Upstream invokes the response hook unconditionally for 

189 both successes and failures (passing exception details on error). However, it 

190 always marks ``span.status`` with an error status before calling the hook. 

191 

192 Because of this disparity, this hook checks ``span.status`` to guard against 

193 async failure callbacks while allowing synchronous and successful asynchronous 

194 calls to be marked "OK". 

195 

196 Note: 

197 If upstream ``opentelemetry-instrumentation-grpc`` adds native support for 

198 modern ``rpc.response.status_code`` in future releases, this hook can be retired. 

199 

200 Args: 

201 span (Optional[Any]): The OpenTelemetry span. 

202 response (Any): The gRPC response object or details. 

203 """ 

204 if span is None or not getattr(span, "is_recording", lambda: False)(): 

205 return 

206 

207 # Guard against upstream async calls that invoke this hook on failures. 

208 status = getattr(span, "status", None) 

209 status_code = getattr(status, "status_code", None) 

210 if ( 

211 getattr(status_code, "name", None) == "ERROR" 

212 or getattr(status_code, "value", None) == 2 

213 ): 

214 return 

215 

216 span.set_attribute("rpc.response.status_code", "OK") 

217 

218 

219def _get_tracer_provider( 

220 client_options: ClientOptions | dict[str, Any] | None = None, 

221) -> opentelemetry.trace.TracerProvider | None: 

222 """Extracts the OpenTelemetry tracer provider from client options if present. 

223 

224 Args: 

225 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options 

226 object or dictionary. 

227 

228 Returns: 

229 opentelemetry.trace.TracerProvider | None: The tracer provider if present, 

230 None otherwise. 

231 """ 

232 if isinstance(client_options, dict): 

233 return client_options.get(_TRACER_PROVIDER) 

234 elif client_options is not None: 

235 return getattr(client_options, _TRACER_PROVIDER, None) 

236 return None 

237 

238 

239try: 

240 # flake8: `grpc` imported conditionally for runtime interceptor base classes 

241 import grpc # noqa: F811 

242 from grpc import ( 

243 StreamStreamClientInterceptor as _SyncStreamStreamClientInterceptor, 

244 ) 

245 from grpc import ( 

246 StreamUnaryClientInterceptor as _SyncStreamUnaryClientInterceptor, 

247 ) 

248 from grpc import ( 

249 UnaryStreamClientInterceptor as _SyncUnaryStreamClientInterceptor, 

250 ) 

251 from grpc import ( 

252 UnaryUnaryClientInterceptor as _SyncUnaryUnaryClientInterceptor, 

253 ) 

254 from grpc.aio import ( 

255 StreamStreamClientInterceptor as _AsyncStreamStreamClientInterceptor, 

256 ) 

257 from grpc.aio import ( 

258 StreamUnaryClientInterceptor as _AsyncStreamUnaryClientInterceptor, 

259 ) 

260 from grpc.aio import ( 

261 UnaryStreamClientInterceptor as _AsyncUnaryStreamClientInterceptor, 

262 ) 

263 from grpc.aio import ( 

264 UnaryUnaryClientInterceptor as _AsyncUnaryUnaryClientInterceptor, 

265 ) 

266except ImportError: # pragma: NO COVER 

267 # mypy: Fallbacks: grpc sentinel when optional grpc is not installed in the environment. 

268 # and distinct empty classes avoid duplicate base class 'object' TypeError at runtime. 

269 grpc = None # type: ignore[assignment] 

270 

271 class _SyncUnaryUnaryClientInterceptor: # type: ignore[no-redef] 

272 __slots__ = () 

273 

274 class _SyncUnaryStreamClientInterceptor: # type: ignore[no-redef] 

275 __slots__ = () 

276 

277 class _SyncStreamUnaryClientInterceptor: # type: ignore[no-redef] 

278 __slots__ = () 

279 

280 class _SyncStreamStreamClientInterceptor: # type: ignore[no-redef] 

281 __slots__ = () 

282 

283 class _AsyncUnaryUnaryClientInterceptor: # type: ignore[no-redef] 

284 __slots__ = () 

285 

286 class _AsyncUnaryStreamClientInterceptor: # type: ignore[no-redef] 

287 __slots__ = () 

288 

289 class _AsyncStreamUnaryClientInterceptor: # type: ignore[no-redef] 

290 __slots__ = () 

291 

292 class _AsyncStreamStreamClientInterceptor: # type: ignore[no-redef] 

293 __slots__ = () 

294 

295 

296class _SuppressingClientInterceptor( 

297 _SyncUnaryUnaryClientInterceptor, 

298 _SyncUnaryStreamClientInterceptor, 

299 _SyncStreamUnaryClientInterceptor, 

300 _SyncStreamStreamClientInterceptor, 

301): 

302 """Client interceptor that suppresses redundant downstream generic auto-instrumentation spans. 

303 

304 When users enable global OpenTelemetry gRPC auto-instrumentation (e.g. GrpcInstrumentorClient), 

305 the underlying channel is wrapped with generic interceptors that emit bare-bones spans. 

306 This interceptor wraps downstream calls in OpenTelemetry's native `suppress_instrumentation` 

307 context manager so that our feature-rich Google Cloud SDK T4 span is emitted while 

308 redundant generic downstream spans are bypassed. 

309 """ 

310 

311 __slots__ = () 

312 

313 def intercept_unary_unary( 

314 self, continuation: Any, client_call_details: Any, request: Any 

315 ) -> Any: 

316 with _suppress_instrumentation(): 

317 return continuation(client_call_details, request) 

318 

319 def intercept_unary_stream( 

320 self, continuation: Any, client_call_details: Any, request: Any 

321 ) -> Any: 

322 with _suppress_instrumentation(): 

323 return continuation(client_call_details, request) 

324 

325 def intercept_stream_unary( 

326 self, continuation: Any, client_call_details: Any, request_iterator: Any 

327 ) -> Any: 

328 with _suppress_instrumentation(): 

329 return continuation(client_call_details, request_iterator) 

330 

331 def intercept_stream_stream( 

332 self, continuation: Any, client_call_details: Any, request_iterator: Any 

333 ) -> Any: 

334 with _suppress_instrumentation(): 

335 return continuation(client_call_details, request_iterator) 

336 

337 

338class _AsyncSuppressingClientInterceptor( 

339 _AsyncUnaryUnaryClientInterceptor, 

340 _AsyncUnaryStreamClientInterceptor, 

341 _AsyncStreamUnaryClientInterceptor, 

342 _AsyncStreamStreamClientInterceptor, 

343): 

344 """AsyncIO client interceptor that suppresses redundant downstream generic auto-instrumentation spans.""" 

345 

346 __slots__ = () 

347 

348 async def intercept_unary_unary( 

349 self, continuation: Any, client_call_details: Any, request: Any 

350 ) -> Any: 

351 with _suppress_instrumentation(): 

352 return await continuation(client_call_details, request) 

353 

354 async def intercept_unary_stream( 

355 self, continuation: Any, client_call_details: Any, request: Any 

356 ) -> Any: 

357 with _suppress_instrumentation(): 

358 return await continuation(client_call_details, request) 

359 

360 async def intercept_stream_unary( 

361 self, continuation: Any, client_call_details: Any, request_iterator: Any 

362 ) -> Any: 

363 with _suppress_instrumentation(): 

364 return await continuation(client_call_details, request_iterator) 

365 

366 async def intercept_stream_stream( 

367 self, continuation: Any, client_call_details: Any, request_iterator: Any 

368 ) -> Any: 

369 with _suppress_instrumentation(): 

370 return await continuation(client_call_details, request_iterator) 

371 

372 

373def get_otel_interceptor( 

374 client_options: ClientOptions | dict[str, Any] | None = None, 

375 default_host: str | None = None, 

376) -> Callable[[grpc.Channel], grpc.Channel] | None: 

377 """Returns an interceptor callable that wraps a sync gRPC channel with OpenTelemetry tracing. 

378 

379 Args: 

380 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options 

381 object or dictionary used for feature gating and extracting the tracer provider. 

382 default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not 

383 set on ``client_options``. 

384 

385 Returns: 

386 Callable[[grpc.Channel], grpc.Channel] | None: An interceptor callable if OpenTelemetry 

387 tracing is enabled and installed, None otherwise. 

388 """ 

389 if not is_otel_capabilities_enabled(client_options): 

390 return None 

391 

392 import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] 

393 

394 endpoint_attrs = _extract_endpoint_attributes( 

395 client_options, default_host=default_host 

396 ) 

397 request_hook = _make_grpc_client_request_hook(endpoint_attrs) 

398 

399 interceptor: ClientInterceptor = otel_grpc.client_interceptor( 

400 tracer_provider=_get_tracer_provider(client_options), 

401 request_hook=request_hook, 

402 response_hook=_grpc_client_response_hook, 

403 ) 

404 suppressor = _SuppressingClientInterceptor() 

405 

406 def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: 

407 if grpc is not None: 

408 try: 

409 channel = grpc.intercept_channel(channel, suppressor) 

410 except (AttributeError, TypeError): 

411 # Fallback if grpc lacks intercept_channel or custom channel type rejects it. 

412 pass 

413 return otel_grpc.intercept_channel(channel, interceptor) 

414 

415 otel_interceptor._is_otel_interceptor = True # type: ignore[attr-defined] 

416 return otel_interceptor 

417 

418 

419def get_otel_async_interceptor( 

420 client_options: ClientOptions | dict[str, Any] | None = None, 

421 default_host: str | None = None, 

422) -> Sequence[grpc.aio.ClientInterceptor] | None: 

423 """Returns async gRPC client interceptors for OpenTelemetry tracing. 

424 

425 Args: 

426 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options 

427 object or dictionary used for feature gating and extracting the tracer provider. 

428 default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not 

429 set on ``client_options``. 

430 

431 Returns: 

432 Sequence[grpc.aio.ClientInterceptor] | None: Instantiated OpenTelemetry async 

433 client interceptors if tracing is enabled and installed, None otherwise. 

434 """ 

435 if not is_otel_capabilities_enabled(client_options): 

436 return None 

437 

438 # Ignored by mypy: Optional dependency only loaded if early-return is skipped 

439 import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] 

440 

441 endpoint_attrs = _extract_endpoint_attributes( 

442 client_options, default_host=default_host 

443 ) 

444 request_hook = _make_grpc_client_request_hook(endpoint_attrs) 

445 

446 raw_interceptors = otel_grpc.aio_client_interceptors( 

447 tracer_provider=_get_tracer_provider(client_options), 

448 request_hook=request_hook, 

449 response_hook=_grpc_client_response_hook, 

450 ) 

451 interceptors = ( 

452 list(raw_interceptors) 

453 if isinstance(raw_interceptors, (list, tuple)) 

454 else [raw_interceptors] 

455 ) 

456 interceptors.append(_AsyncSuppressingClientInterceptor()) 

457 

458 return interceptors 

459 

460 

461_CHANNEL_INTERCEPTOR_MAPPING = ( 

462 ("intercept_unary_unary", "_unary_unary_interceptors"), 

463 ("intercept_unary_stream", "_unary_stream_interceptors"), 

464 ("intercept_stream_unary", "_stream_unary_interceptors"), 

465 ("intercept_stream_stream", "_stream_stream_interceptors"), 

466) 

467 

468 

469def apply_otel_async_channel_interceptors( 

470 channel: Any, 

471 client_options: ClientOptions | dict[str, Any] | Sequence[Any] | None = None, 

472 default_host: str | None = None, 

473) -> Any: 

474 """Creates and attaches OpenTelemetry client interceptors to the front of an async gRPC channel. 

475 

476 In grpc.aio, channels maintain internal interceptor lists evaluated in index order. 

477 To ensure Google Cloud SDK telemetry wraps outside downstream interceptors and 

478 suppresses ambient generic auto-instrumentation, our interceptors are prepended 

479 to the front of the channel's interceptor lists. 

480 

481 Args: 

482 channel: The async gRPC channel to intercept. 

483 client_options (Optional[Union[ClientOptions, dict[str, Any], Sequence[Any]]]): The client 

484 options used to resolve feature flags and tracer provider, or a sequence of 

485 pre-resolved interceptors. 

486 default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not 

487 set on ``client_options``. 

488 

489 Returns: 

490 The channel with interceptors prepended to its internal lists, 

491 or the original channel if tracing is disabled or not installed. 

492 """ 

493 otel_interceptors: Sequence[Any] | None 

494 if isinstance(client_options, (ClientOptions, dict)) or client_options is None: 

495 otel_interceptors = get_otel_async_interceptor( 

496 client_options, default_host=default_host 

497 ) 

498 elif isinstance(client_options, Sequence): 

499 otel_interceptors = client_options 

500 else: 

501 otel_interceptors = None 

502 

503 if not otel_interceptors: 

504 return channel 

505 

506 interceptor_list = list(otel_interceptors) 

507 

508 # Section 1: Typed interceptors - prepend matching interceptors to each 

509 # corresponding channel list (unary-unary, unary-stream, etc.) in order. 

510 # See note in the docstring about why prepending is important here. 

511 for method_name, attr_name in _CHANNEL_INTERCEPTOR_MAPPING: 

512 target_list = getattr(channel, attr_name, None) 

513 if isinstance(target_list, list): 

514 matching = [ 

515 i 

516 for i in interceptor_list 

517 if hasattr(i, method_name) and i not in target_list 

518 ] 

519 if matching: 

520 target_list[:0] = matching 

521 

522 # Section 2: Untyped/generic fallback - prepend any remaining interceptors 

523 # lacking standard method names directly to the unary-unary list. 

524 unary_list = getattr(channel, "_unary_unary_interceptors", None) 

525 if isinstance(unary_list, list): 

526 standard_methods = tuple(method for method, _ in _CHANNEL_INTERCEPTOR_MAPPING) 

527 fallback_items = [ 

528 i 

529 for i in interceptor_list 

530 if not any(hasattr(i, m) for m in standard_methods) and i not in unary_list 

531 ] 

532 if fallback_items: 

533 unary_list[:0] = fallback_items 

534 

535 return channel 

536 

537 

538_TRACE_CONTEXT_PROPAGATOR: Any = None 

539 

540 

541class _TraceContext: 

542 """Context manager for tracing an HTTP wire request with OpenTelemetry. 

543 

544 Manages span creation, semantic attribute attachment, W3C traceparent injection, 

545 and automatic error recording on failure without suppressing caller exceptions. 

546 

547 Supports two calling conventions: 

548 - Pass a single `request` object (such as `requests.PreparedRequest`). 

549 - Pass explicit keyword arguments (`method`, `url`, `headers`, `body`, `client_options`). 

550 """ 

551 

552 @staticmethod 

553 def _get_trace_context_propagator() -> Any: 

554 global _TRACE_CONTEXT_PROPAGATOR 

555 if _TRACE_CONTEXT_PROPAGATOR is None: 

556 from opentelemetry.trace.propagation.tracecontext import ( # type: ignore[import-not-found] 

557 TraceContextTextMapPropagator, 

558 ) 

559 

560 _TRACE_CONTEXT_PROPAGATOR = TraceContextTextMapPropagator() 

561 return _TRACE_CONTEXT_PROPAGATOR 

562 

563 # The HTTP tracing context manager deliberately supports two distinct invocation styles: 

564 # 1. Bundled Request Object: `trace_http_request(request, ...)` 

565 # Used when callers already possess an HTTP request instance (such as 

566 # requests.PreparedRequest or urllib.request.Request) with `.method`, `.url`, etc. 

567 # 2. Unpacked Keyword Arguments: `trace_http_request(method=..., url=..., headers=..., body=...)` 

568 # Used by generated GAPIC REST transports (_shared_macros.j2). 

569 # In GAPIC templates, requests are assembled from local strings and dictionaries before 

570 # hitting the session. Supporting keyword arguments avoids the CPU and memory overhead 

571 # of instantiating a throwaway dummy request object on every single RPC execution. 

572 @staticmethod 

573 def _build_http_span_attributes( 

574 request: Any = None, 

575 *, 

576 method: str | None = None, 

577 url: str | None = None, 

578 url_template: str | None = None, 

579 headers: dict[str, Any] | None = None, 

580 body: Any = None, 

581 client_options: ClientOptions | dict[str, Any] | None = None, 

582 ) -> tuple[str, dict[str, Any], Any]: 

583 """Extract span name, semantic attributes dictionary, and resolved headers. 

584 

585 Supports two calling conventions: 

586 - Pass a single `request` object (such as `requests.PreparedRequest`). 

587 - Pass explicit keyword arguments (`method`, `url`, `headers`, `body`, `client_options`). 

588 

589 Returns: 

590 tuple[str, dict[str, Any], Any]: A tuple of (span_name, attributes, resolved_headers). 

591 """ 

592 if request is not None: 

593 resolved_method = getattr(request, "method", "HTTP") or "HTTP" 

594 resolved_url = getattr(request, "url", "") or "" 

595 resolved_headers = getattr(request, "headers", None) 

596 resolved_body = getattr(request, "body", None) 

597 else: 

598 resolved_method = method or "HTTP" 

599 resolved_url = url or "" 

600 resolved_headers = headers 

601 resolved_body = body 

602 

603 resolved_method = resolved_method.upper() 

604 endpoint_attrs = _extract_endpoint_attributes(client_options) 

605 

606 server_address = endpoint_attrs.get("server.address") 

607 server_port = endpoint_attrs.get("server.port") 

608 if not server_address and resolved_url: 

609 try: 

610 parsed = urllib.parse.urlsplit(resolved_url) 

611 server_address = parsed.hostname 

612 if not server_port and parsed.port: 

613 server_port = parsed.port 

614 except Exception: # Fail-open on malformed URL parsing 

615 pass 

616 

617 span_name = ( 

618 f"{resolved_method} {url_template}" if url_template else resolved_method 

619 ) 

620 span_attributes: dict[str, Any] = { 

621 "rpc.system.name": "http", 

622 "http.request.method": resolved_method, 

623 "server.address": server_address or "", 

624 "server.port": server_port or 443, 

625 "url.domain": endpoint_attrs.get("url.domain", "googleapis.com"), 

626 } 

627 if url_template: 

628 span_attributes["url.template"] = url_template 

629 if resolved_url: 

630 span_attributes["url.full"] = resolved_url 

631 

632 if resolved_body is not None and isinstance(resolved_body, (bytes, str)): 

633 span_attributes["http.request.body.size"] = len(resolved_body) 

634 

635 return span_name, span_attributes, resolved_headers 

636 

637 @staticmethod 

638 def _extract_http_error_message(response: Any) -> str | None: 

639 """Extract a human-readable error message from an HTTP error response.""" 

640 json_fn = getattr(response, "json", None) 

641 if callable(json_fn): 

642 try: 

643 payload = json_fn() 

644 if isinstance(payload, dict): 

645 err_obj = payload.get("error") 

646 if isinstance(err_obj, dict): 

647 msg = err_obj.get("message") 

648 if isinstance(msg, str) and msg: 

649 return msg 

650 elif isinstance(err_obj, str) and err_obj: 

651 return err_obj 

652 # Intentionally catch broad Exception so telemetry extraction 

653 # fails open and never disrupts the caller's RPC flow. 

654 except Exception: 

655 pass 

656 for attr in ("text", "reason"): 

657 try: 

658 val = getattr(response, attr, None) 

659 if isinstance(val, str) and val.strip(): 

660 return val.strip() 

661 # Intentionally catch broad Exception so telemetry extraction 

662 # fails open and never disrupts the caller's RPC flow. 

663 except Exception: 

664 continue 

665 return None 

666 

667 def __init__( 

668 self, 

669 request: Any = None, 

670 *, 

671 method: str | None = None, 

672 url: str | None = None, 

673 url_template: str | None = None, 

674 headers: dict[str, Any] | None = None, 

675 body: Any = None, 

676 client_options: ClientOptions | dict[str, Any] | None = None, 

677 ): 

678 self._request = request 

679 self._method = method 

680 self._url = url 

681 self._url_template = url_template 

682 self._headers = headers 

683 self._body = body 

684 self._client_options = client_options 

685 self._span: Any = None 

686 self._parent_span: Any = None 

687 self._cm: Any = None 

688 

689 def record_response(self, response: Any) -> None: 

690 """Record HTTP response attributes on the active span. 

691 

692 Args: 

693 response (Any): The HTTP response object (e.g. requests.Response). 

694 """ 

695 span = self._span 

696 if span is None or not hasattr(span, "set_attribute"): 

697 return 

698 

699 try: 

700 from opentelemetry.trace.status import ( # type: ignore[import-not-found] 

701 Status, 

702 StatusCode, 

703 ) 

704 

705 status_code = getattr( 

706 response, "status_code", getattr(response, "status", None) 

707 ) 

708 if status_code is not None: 

709 code_int = int(status_code) 

710 span.set_attribute("http.response.status_code", code_int) 

711 if self._parent_span is not None: 

712 self._parent_span.set_attribute( 

713 "http.response.status_code", code_int 

714 ) 

715 if code_int >= 400: 

716 span.set_status(Status(StatusCode.ERROR)) 

717 span.set_attribute("error.type", str(code_int)) 

718 error_msg = self._extract_http_error_message(response) 

719 if error_msg: 

720 span.set_attribute("status.message", error_msg) 

721 

722 headers = getattr(response, "headers", None) 

723 if headers and "Content-Length" in headers: 

724 try: 

725 span.set_attribute( 

726 "http.response.body.size", int(headers["Content-Length"]) 

727 ) 

728 except (ValueError, TypeError): 

729 pass 

730 elif hasattr(response, "_content") and response._content is not None: 

731 try: 

732 span.set_attribute( 

733 "http.response.body.size", len(response._content) 

734 ) 

735 except Exception: 

736 pass 

737 except Exception: 

738 pass 

739 

740 # Alias for method name parity with Daniel's suggested convention 

741 record_http_response = record_response 

742 

743 def record_error(self, exc: BaseException | None) -> None: 

744 """Record supplemental Cloud Observability error attributes on the active span. 

745 

746 Upstream OpenTelemetry's ``start_as_current_span`` context manager automatically 

747 records the exception event and marks the span status as ERROR when an unhandled 

748 exception escapes (via its default ``record_exception=True`` and 

749 ``set_status_on_exception=True`` settings). 

750 

751 This method supplements that base OpenTelemetry behavior by enriching the span 

752 with semantic attributes required by Cloud Trace and the Google Cloud Observability 

753 specification: 

754 - ``error.type``: Low-cardinality status code string (e.g. "404") or exception class name. 

755 - ``status.message``: Error description message. 

756 

757 Args: 

758 exc (Optional[BaseException]): The exception raised during dispatch. 

759 """ 

760 span = self._span 

761 if span is None or exc is None: 

762 return 

763 

764 try: 

765 if hasattr(span, "set_attribute"): 

766 status_code = getattr(exc, "code", None) or getattr( 

767 exc, "status_code", None 

768 ) 

769 if status_code: 

770 span.set_attribute("error.type", str(status_code)) 

771 else: 

772 span.set_attribute("error.type", exc.__class__.__name__) 

773 msg = str(exc) 

774 if msg: 

775 span.set_attribute("status.message", msg) 

776 except Exception: # Fail-open on error attribute extraction failure 

777 pass 

778 

779 # Alias for method name parity with Daniel's suggested convention 

780 record_http_error = record_error 

781 

782 def __enter__(self) -> "_TraceContext": 

783 if not is_otel_capabilities_enabled(self._client_options): 

784 return self 

785 

786 try: 

787 from opentelemetry import trace 

788 

789 tracer_provider = _get_tracer_provider(self._client_options) 

790 if tracer_provider is not None: 

791 tracer = tracer_provider.get_tracer("google.api_core") 

792 else: 

793 tracer = trace.get_tracer("google.api_core") 

794 

795 span_name, span_attributes, resolved_headers = ( 

796 self._build_http_span_attributes( 

797 self._request, 

798 method=self._method, 

799 url=self._url, 

800 url_template=self._url_template, 

801 headers=self._headers, 

802 body=self._body, 

803 client_options=self._client_options, 

804 ) 

805 ) 

806 

807 parent_span = trace.get_current_span() 

808 if ( 

809 parent_span is not None 

810 and getattr(parent_span, "is_recording", lambda: False)() is True 

811 and hasattr(parent_span, "set_attribute") 

812 ): 

813 self._parent_span = parent_span 

814 if self._url_template: 

815 try: 

816 parent_attrs = getattr(parent_span, "attributes", None) 

817 if parent_attrs is None or "url.template" not in parent_attrs: 

818 parent_span.set_attribute( 

819 "url.template", self._url_template 

820 ) 

821 # Intentionally catch broad Exception so telemetry extraction 

822 # fails open and never disrupts the caller's RPC flow. 

823 except Exception: 

824 pass 

825 

826 self._cm = tracer.start_as_current_span( 

827 span_name, 

828 kind=trace.SpanKind.CLIENT, 

829 attributes=span_attributes, 

830 ) 

831 self._span = self._cm.__enter__() 

832 

833 if resolved_headers is not None and hasattr( 

834 resolved_headers, "__setitem__" 

835 ): 

836 try: 

837 self._get_trace_context_propagator().inject(resolved_headers) 

838 except Exception: # Fail-open on header injection failure 

839 pass 

840 

841 return self 

842 except Exception: 

843 # Fail-open: telemetry failures must never disrupt core RPC execution 

844 return self 

845 

846 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 

847 if self._cm is not None: 

848 try: 

849 if exc_val is not None and self._span is not None: 

850 self.record_error(exc_val) 

851 finally: 

852 self._cm.__exit__(exc_type, exc_val, exc_tb) 

853 # Always return None so caller exceptions are never suppressed 

854 return None 

855 

856 

857def trace_http_request( 

858 request: Any = None, 

859 *, 

860 method: str | None = None, 

861 url: str | None = None, 

862 url_template: str | None = None, 

863 headers: dict[str, Any] | None = None, 

864 body: Any = None, 

865 client_options: ClientOptions | dict[str, Any] | None = None, 

866) -> _TraceContext: 

867 """Context manager factory for tracing an HTTP wire request with OpenTelemetry. 

868 

869 Manages span creation, semantic attribute attachment, W3C traceparent injection, 

870 and automatic error recording on failure without suppressing caller exceptions. 

871 

872 Supports two calling conventions: 

873 1. Bundled Request Object: `trace_http_request(request, ...)` 

874 Used when callers already possess an HTTP request instance (such as 

875 `requests.PreparedRequest` or `urllib.request.Request`) with `.method`, `.url`, etc. 

876 2. Unpacked Keyword Arguments: `trace_http_request(method=..., url=..., headers=..., body=...)` 

877 Used by generated GAPIC REST transports (_shared_macros.j2). 

878 In GAPIC templates, requests are assembled from local strings and dictionaries before 

879 hitting the session. Supporting keyword arguments avoids the CPU and memory overhead 

880 of instantiating a throwaway dummy request object on every single RPC execution. 

881 

882 Args: 

883 request (Optional[Any]): Bundled HTTP request object with .method, .url, .headers. 

884 method (Optional[str]): Explicit HTTP method (e.g. "GET", "POST"). 

885 url (Optional[str]): Fully qualified request URL. 

886 url_template (Optional[str]): Parameterized path template for url.template. 

887 headers (Optional[dict[str, Any]]): Mutable dictionary of request headers. 

888 body (Optional[Any]): Request payload (used for body size calculation). 

889 client_options (Optional[Union[ClientOptions, dict[str, Any]]]): Client options 

890 containing custom endpoint and tracer provider configurations. 

891 

892 Returns: 

893 _TraceContext: An active context manager managing the HTTP client span. 

894 """ 

895 return _TraceContext( 

896 request=request, 

897 method=method, 

898 url=url, 

899 url_template=url_template, 

900 headers=headers, 

901 body=body, 

902 client_options=client_options, 

903 )