From 95ca42a7eead9b57ef07a35dcd42b4f675d1801a Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 3 Sep 2026 09:50:23 -0400 Subject: [PATCH 1/3] feat(gapic): add OpenTelemetry T3 client method span wrapping --- .../google/api_core/gapic_v1/method.py | 39 ++++++- .../tests/unit/gapic/test_method.py | 100 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method.py b/packages/google-api-core/google/api_core/gapic_v1/method.py index ecd54d0aef62..aceecdd5e96c 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method.py @@ -22,7 +22,7 @@ import functools from typing import List, Tuple -from google.api_core import grpc_helpers +from google.api_core import _observability, grpc_helpers from google.api_core.gapic_v1 import client_info from google.api_core.timeout import TimeToDeadlineTimeout @@ -125,6 +125,8 @@ class _GapicCallable(object): additional metadata will be passed to the RPC method. """ + _is_tracing_supported = True + def __init__( self, target, @@ -186,6 +188,41 @@ def __call__( if self._compression is not None: kwargs["compression"] = compression + if _observability.is_otel_capabilities_enabled(): + try: + from opentelemetry import trace + + tracer = trace.get_tracer("google.api_core") + raw_method = getattr(self._target, "_method", None) + if raw_method and isinstance(raw_method, (str, bytes)): + if isinstance(raw_method, bytes): + raw_method = raw_method.decode("utf-8") + method_str = raw_method.lstrip("/") + service, _, method = method_str.rpartition("/") + span_name = method_str + else: + service = "google.api_core" + method = getattr(self._target, "__name__", "call") + span_name = f"{service}/{method}" + + with tracer.start_as_current_span( + span_name, + kind=trace.SpanKind.CLIENT, + attributes={ + "rpc.system": "grpc", + "rpc.service": service, + "rpc.method": method, + }, + ) as span: + try: + return wrapped_func(*args, **kwargs) + except Exception as exc: + span.record_exception(exc) + span.set_status(trace.StatusCode.ERROR, str(exc)) + raise + except ImportError: + pass + return wrapped_func(*args, **kwargs) diff --git a/packages/google-api-core/tests/unit/gapic/test_method.py b/packages/google-api-core/tests/unit/gapic/test_method.py index fbe7f2a5f0f1..6f5ab8c1bd9a 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method.py +++ b/packages/google-api-core/tests/unit/gapic/test_method.py @@ -13,6 +13,7 @@ # limitations under the License. import datetime +import sys from unittest import mock import pytest @@ -346,3 +347,102 @@ def test_wrap_method_with_call_not_supported(): def test__deduplicate_metadata_tokens(headers, expected): dedup = google.api_core.gapic_v1.method._deduplicate_metadata_tokens assert dedup(*headers) == expected + + +def test_wrap_method_otel_tracing_disabled(monkeypatch): + """Proves that when OpenTelemetry tracing is disabled, no span is created.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false") + mock_target = mock.Mock(return_value="success") + wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target) + + with mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=False, + ): + assert wrapped() == "success" + mock_target.assert_called_once() + + +def test_wrap_method_otel_tracing_enabled_success(monkeypatch): + """Proves that when OpenTelemetry tracing is enabled, a T3 client span is started.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + mock_target = mock.Mock(return_value="success") + mock_target._method = ( + "/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + + wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + result = wrapped() + + assert result == "success" + mock_tracer.start_as_current_span.assert_called_once_with( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="CLIENT", + attributes={ + "rpc.system": "grpc", + "rpc.service": "google.cloud.secretmanager.v1.SecretManagerService", + "rpc.method": "ListSecrets", + }, + ) + + +def test_wrap_method_otel_tracing_enabled_error(monkeypatch): + """Proves that when an RPC fails, the T3 client span records the exception and error status.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + err = RuntimeError("gRPC connection reset") + mock_target = mock.Mock(side_effect=err) + mock_target._method = ( + "/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + mock_trace.StatusCode.ERROR = "ERROR" + + wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + with pytest.raises(RuntimeError): + wrapped() + + mock_span.record_exception.assert_called_once_with(err) + mock_span.set_status.assert_called_once_with("ERROR", str(err)) From 4cd28f0bb9b24c16ea7a0a01a49a12d2ab85029b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 3 Sep 2026 10:43:15 -0400 Subject: [PATCH 2/3] chore(gapic): remove _is_tracing_supported dummy class variable --- packages/google-api-core/google/api_core/gapic_v1/method.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method.py b/packages/google-api-core/google/api_core/gapic_v1/method.py index aceecdd5e96c..d2022756f3b2 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method.py @@ -125,8 +125,6 @@ class _GapicCallable(object): additional metadata will be passed to the RPC method. """ - _is_tracing_supported = True - def __init__( self, target, From 9b46ef76c40c7b187a0ea36ce93149afad631b01 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 4 Sep 2026 11:18:49 -0400 Subject: [PATCH 3/3] test(gapic): achieve 100% branch and statement coverage for OTel T3 method tracing --- .../google/api_core/gapic_v1/method.py | 3 +- .../tests/unit/gapic/test_method.py | 212 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method.py b/packages/google-api-core/google/api_core/gapic_v1/method.py index d2022756f3b2..dceb094f5568 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method.py @@ -218,7 +218,8 @@ def __call__( span.record_exception(exc) span.set_status(trace.StatusCode.ERROR, str(exc)) raise - except ImportError: + # If OpenTelemetry cannot be imported in the current environment, continue without tracing. + except ImportError: # pragma: NO COVER pass return wrapped_func(*args, **kwargs) diff --git a/packages/google-api-core/tests/unit/gapic/test_method.py b/packages/google-api-core/tests/unit/gapic/test_method.py index 6f5ab8c1bd9a..c337a31fe6f6 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method.py +++ b/packages/google-api-core/tests/unit/gapic/test_method.py @@ -446,3 +446,215 @@ def test_wrap_method_otel_tracing_enabled_error(monkeypatch): mock_span.record_exception.assert_called_once_with(err) mock_span.set_status.assert_called_once_with("ERROR", str(err)) + + +def test_wrap_method_otel_tracing_bytes_method(monkeypatch): + """Proves that when raw _method is bytes, it is decoded properly to utf-8.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + mock_target = mock.Mock(return_value="success") + mock_target._method = ( + b"/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + + wrapped = google.api_core.gapic_v1.method.wrap_method( + mock_target, default_timeout=60 + ) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + result = wrapped() + + assert result == "success" + mock_tracer.start_as_current_span.assert_called_once_with( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="CLIENT", + attributes={ + "rpc.system": "grpc", + "rpc.service": "google.cloud.secretmanager.v1.SecretManagerService", + "rpc.method": "ListSecrets", + }, + ) + + +def test_wrap_method_otel_tracing_fallback_with_name(monkeypatch): + """Proves that when raw _method is absent, fallback uses target.__name__.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + def custom_rpc(*args, **kwargs): + return "success" + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + + wrapped = google.api_core.gapic_v1.method.wrap_method(custom_rpc) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + result = wrapped() + + assert result == "success" + mock_tracer.start_as_current_span.assert_called_once_with( + "google.api_core/custom_rpc", + kind="CLIENT", + attributes={ + "rpc.system": "grpc", + "rpc.service": "google.api_core", + "rpc.method": "custom_rpc", + }, + ) + + +def test_wrap_method_otel_tracing_fallback_without_name(monkeypatch): + """Proves that when raw _method is absent and target has no explicit __name__, + fallback uses target class name assigned by error wrapper. + """ + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + class TargetWithoutName: + def __call__(self, *args, **kwargs): + return "success" + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + + wrapped = google.api_core.gapic_v1.method.wrap_method(TargetWithoutName()) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + result = wrapped() + + assert result == "success" + mock_tracer.start_as_current_span.assert_called_once_with( + "google.api_core/TargetWithoutName", + kind="CLIENT", + attributes={ + "rpc.system": "grpc", + "rpc.service": "google.api_core", + "rpc.method": "TargetWithoutName", + }, + ) + + +def test_gapic_callable_otel_tracing_fallback_call_default(monkeypatch): + """Proves that _GapicCallable defaults method to 'call' if target has no __name__.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + class NoNameTarget: + def __call__(self, *args, **kwargs): + return "success" + + target = NoNameTarget() + callable_obj = google.api_core.gapic_v1.method._GapicCallable( + target, None, None, None + ) + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_trace = mock.Mock() + mock_trace.get_tracer.return_value = mock_tracer + mock_trace.SpanKind.CLIENT = "CLIENT" + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + result = callable_obj() + + assert result == "success" + mock_tracer.start_as_current_span.assert_called_once_with( + "google.api_core/call", + kind="CLIENT", + attributes={ + "rpc.system": "grpc", + "rpc.service": "google.api_core", + "rpc.method": "call", + }, + ) + + +def test_wrap_method_otel_tracing_import_error(monkeypatch): + """Proves that if opentelemetry raises ImportError, execution proceeds gracefully.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + mock_target = mock.Mock(return_value="success") + + wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target) + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict( + sys.modules, + { + "opentelemetry": None, + "opentelemetry.trace": None, + }, + ), + ): + result = wrapped() + + assert result == "success" + mock_target.assert_called_once()