From 45ef7ac5a8f04ccef88aa776686368bb31b61358 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 08:00:49 -0400 Subject: [PATCH 01/30] feat(api-core): add tracer_provider to ClientOptions for OTel support --- packages/google-api-core/google/api_core/client_options.py | 4 ++++ packages/google-api-core/tests/unit/test_client_options.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/client_options.py b/packages/google-api-core/google/api_core/client_options.py index 68c4644245ce..ccf731fe6d8c 100644 --- a/packages/google-api-core/google/api_core/client_options.py +++ b/packages/google-api-core/google/api_core/client_options.py @@ -98,6 +98,8 @@ class ClientOptions(object): `googleapis.com`. If both `api_endpoint` and `universe_domain` are set, then `api_endpoint` is used as the service endpoint. If `api_endpoint` is not specified, the format will be `{service}.{universe_domain}`. + tracer_provider (Optional[object]): The OpenTelemetry tracer provider to use + for tracing. If not set, the global tracer provider will be used. Raises: ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source`` @@ -117,6 +119,7 @@ def __init__( api_key: Optional[str] = None, api_audience: Optional[str] = None, universe_domain: Optional[str] = None, + tracer_provider: Optional[object] = None, ): if credentials_file is not None: warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) @@ -136,6 +139,7 @@ def __init__( self.api_key = api_key self.api_audience = api_audience self.universe_domain = universe_domain + self.tracer_provider = tracer_provider def __repr__(self) -> str: return "ClientOptions: " + repr(self.__dict__) diff --git a/packages/google-api-core/tests/unit/test_client_options.py b/packages/google-api-core/tests/unit/test_client_options.py index 5d68232219f1..c15e83174ed4 100644 --- a/packages/google-api-core/tests/unit/test_client_options.py +++ b/packages/google-api-core/tests/unit/test_client_options.py @@ -15,7 +15,6 @@ from re import match import pytest - from google.api_core import client_options from ..helpers import warn_deprecated_credentials_file @@ -30,6 +29,7 @@ def get_client_encrypted_cert(): def test_constructor(): + mock_tracer_provider = object() with warn_deprecated_credentials_file(): options = client_options.ClientOptions( api_endpoint="foo.googleapis.com", @@ -42,6 +42,7 @@ def test_constructor(): ], api_audience="foo2.googleapis.com", universe_domain="googleapis.com", + tracer_provider=mock_tracer_provider, ) assert options.api_endpoint == "foo.googleapis.com" @@ -54,6 +55,7 @@ def test_constructor(): ] assert options.api_audience == "foo2.googleapis.com" assert options.universe_domain == "googleapis.com" + assert options.tracer_provider is mock_tracer_provider def test_constructor_with_encrypted_cert_source(): @@ -162,6 +164,7 @@ def test_repr(): "scopes", "api_key", "api_audience", + "tracer_provider", ] ) options = client_options.ClientOptions(api_endpoint="foo.googleapis.com") From 5a84a8ff9adf5b3712687d31776c1d63ad7c2469 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 09:40:25 -0400 Subject: [PATCH 02/30] feat(api-core): add _otel_helpers to centralize OTel interceptor resolution --- .../google/api_core/_otel_helpers.py | 61 ++++++++++++++ .../tests/unit/test_otel_helpers.py | 81 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/google-api-core/google/api_core/_otel_helpers.py create mode 100644 packages/google-api-core/tests/unit/test_otel_helpers.py diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py new file mode 100644 index 000000000000..836e086b5455 --- /dev/null +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""OpenTelemetry helpers for resolving and instantiating interceptors.""" + +from typing import Any, Optional + +from google.api_core import _feature_gating_helpers +from google.api_core.client_options import ClientOptions + + +def get_otel_grpc_interceptor( + client_options: Optional[ClientOptions] = None, + env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", +) -> Optional[Any]: + """Checks feature flags, attempts to import OTel, and returns the interceptor. + + This helper centralizes the logic for checking environment variables, + programmatic configuration (via ClientOptions), and soft-importing + the `opentelemetry-instrumentation-grpc` package. + + Args: + client_options: The client options object, potentially holding a tracer_provider. + env_var: The environment variable to check for enablement. + + Returns: + Optional[Any]: An OpenTelemetry gRPC client interceptor instance if enabled + and installed, otherwise None. + """ + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var=env_var, + feature_key="tracer_provider", + configuration=client_options, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + + tracer_provider = getattr(client_options, "tracer_provider", None) + return otel_grpc.client_interceptor(tracer_provider=tracer_provider) + except ImportError: + # Failed open if OTel is not installed but feature was requested. + # We might want to warn here, but for now we follow the "silent fail open" pattern + # or let the user handle it if they care. + pass + + return None diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py new file mode 100644 index 000000000000..42574343d94e --- /dev/null +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -0,0 +1,81 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from unittest import mock + +import pytest +from google.api_core import _otel_helpers +from google.api_core.client_options import ClientOptions + + +def test_get_otel_grpc_interceptor_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is None + + +def test_get_otel_grpc_interceptor_enabled_otel_missing(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + # Simulate OTel not being installed by blocking imports + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is None + + +def test_get_otel_grpc_interceptor_enabled_otel_installed(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) + + +def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): + # Tracing enabled via config (tracer_provider is set) + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) From 343e4caaabbc003a5432fae9092f6eb0aeda5271 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 13:07:58 -0400 Subject: [PATCH 03/30] feat(api-core): support dictionaries in _otel_helpers --- .../google/api_core/_otel_helpers.py | 12 ++++++--- .../tests/unit/test_otel_helpers.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 836e086b5455..7f50b299708b 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -23,17 +23,17 @@ def get_otel_grpc_interceptor( - client_options: Optional[ClientOptions] = None, + client_options: Optional[ClientOptions | dict[str, Any]] = None, env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", ) -> Optional[Any]: """Checks feature flags, attempts to import OTel, and returns the interceptor. This helper centralizes the logic for checking environment variables, - programmatic configuration (via ClientOptions), and soft-importing + programmatic configuration (via ClientOptions or dict), and soft-importing the `opentelemetry-instrumentation-grpc` package. Args: - client_options: The client options object, potentially holding a tracer_provider. + client_options: The client options object or dictionary, potentially holding a tracer_provider. env_var: The environment variable to check for enablement. Returns: @@ -50,7 +50,11 @@ def get_otel_grpc_interceptor( try: import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - tracer_provider = getattr(client_options, "tracer_provider", None) + if isinstance(client_options, dict): + tracer_provider = client_options.get("tracer_provider") + else: + tracer_provider = getattr(client_options, "tracer_provider", None) + return otel_grpc.client_interceptor(tracer_provider=tracer_provider) except ImportError: # Failed open if OTel is not installed but feature was requested. diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 42574343d94e..1cd53f32a655 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -79,3 +79,28 @@ def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider ) + + +def test_get_otel_grpc_interceptor_enabled_via_dict_config(monkeypatch): + # Tracing enabled via dict config + mock_tracer_provider = object() + options = {"tracer_provider": mock_tracer_provider} + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) From 9fa7af9507f5a37c92b3ddcb4815e3b0f57495b4 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Tue, 18 Aug 2026 13:16:21 -0400 Subject: [PATCH 04/30] Removed pytest. --- packages/google-api-core/tests/unit/test_otel_helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 1cd53f32a655..550342a11269 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -15,7 +15,6 @@ import sys from unittest import mock -import pytest from google.api_core import _otel_helpers from google.api_core.client_options import ClientOptions From 5f8f97db0c5bdec4ea54dc5c4a996c19890151cb Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 21 Aug 2026 10:15:45 -0400 Subject: [PATCH 05/30] fix(api-core): safely resolve tracer_provider from client_options avoiding getattr(None) --- packages/google-api-core/google/api_core/_otel_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 7f50b299708b..bffea2ad333e 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -50,9 +50,10 @@ def get_otel_grpc_interceptor( try: import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + tracer_provider = None if isinstance(client_options, dict): tracer_provider = client_options.get("tracer_provider") - else: + elif client_options is not None: tracer_provider = getattr(client_options, "tracer_provider", None) return otel_grpc.client_interceptor(tracer_provider=tracer_provider) From ef05d0e5b8ae41360d322ce1ab9de88b496af94e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 09:02:44 -0400 Subject: [PATCH 06/30] feat(api-core): replace get_otel_grpc_interceptor with eager channel injection helpers --- .../google/api_core/_otel_helpers.py | 56 +++++++++++--- .../tests/unit/test_otel_helpers.py | 74 ++++++++++++------- 2 files changed, 92 insertions(+), 38 deletions(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index bffea2ad333e..afd25640fe42 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -22,23 +22,54 @@ from google.api_core.client_options import ClientOptions -def get_otel_grpc_interceptor( +def is_otel_capabilities_enabled( client_options: Optional[ClientOptions | dict[str, Any]] = None, env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", -) -> Optional[Any]: - """Checks feature flags, attempts to import OTel, and returns the interceptor. +) -> bool: + """Checks if OTel capabilities are enabled and installed. + + Args: + client_options: The client options object or dictionary. + env_var: The environment variable to check for enablement. + + Returns: + bool: True if enabled and installed, False otherwise. + """ + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var=env_var, + feature_key="tracer_provider", + configuration=client_options, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + return True + except ImportError: + pass + + return False + + +def apply_otel_capabilities_to_channel( + channel: Any, + client_options: Optional[ClientOptions | dict[str, Any]] = None, + env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", +) -> Any: + """Applies OTel capabilities (like tracing) to the channel if enabled and installed. This helper centralizes the logic for checking environment variables, - programmatic configuration (via ClientOptions or dict), and soft-importing - the `opentelemetry-instrumentation-grpc` package. + programmatic configuration, and applying OTel interceptors directly to the + channel using specialized OTel wrappers (avoiding standard gRPC TypeError). Args: - client_options: The client options object or dictionary, potentially holding a tracer_provider. + channel: The raw gRPC channel to wrap. + client_options: The client options object or dictionary. env_var: The environment variable to check for enablement. Returns: - Optional[Any]: An OpenTelemetry gRPC client interceptor instance if enabled - and installed, otherwise None. + Any: The intercepted channel if OTel is enabled and applied, + otherwise the original channel. """ is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( env_var=env_var, @@ -56,11 +87,12 @@ def get_otel_grpc_interceptor( elif client_options is not None: tracer_provider = getattr(client_options, "tracer_provider", None) - return otel_grpc.client_interceptor(tracer_provider=tracer_provider) + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + + # 🎯 We use OTel's own compatible applier! + return otel_grpc.intercept_channel(channel, interceptor) except ImportError: # Failed open if OTel is not installed but feature was requested. - # We might want to warn here, but for now we follow the "silent fail open" pattern - # or let the user handle it if they care. pass - return None + return channel diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 550342a11269..f093a36fdef8 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -19,28 +19,24 @@ from google.api_core.client_options import ClientOptions -def test_get_otel_grpc_interceptor_disabled(monkeypatch): +def test_is_otel_capabilities_enabled_disabled(monkeypatch): monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") - interceptor = _otel_helpers.get_otel_grpc_interceptor() - assert interceptor is None + assert not _otel_helpers.is_otel_capabilities_enabled() -def test_get_otel_grpc_interceptor_enabled_otel_missing(monkeypatch): +def test_is_otel_capabilities_enabled_otel_missing(monkeypatch): monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") # Simulate OTel not being installed by blocking imports monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) - interceptor = _otel_helpers.get_otel_grpc_interceptor() - assert interceptor is None + assert not _otel_helpers.is_otel_capabilities_enabled() -def test_get_otel_grpc_interceptor_enabled_otel_installed(monkeypatch): +def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc - mock_interceptor = mock.Mock() - mock_otel_grpc.client_interceptor.return_value = mock_interceptor monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( @@ -50,20 +46,39 @@ def test_get_otel_grpc_interceptor_enabled_otel_installed(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - interceptor = _otel_helpers.get_otel_grpc_interceptor() - assert interceptor is mock_interceptor - mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) + assert _otel_helpers.is_otel_capabilities_enabled() -def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): - # Tracing enabled via config (tracer_provider is set) - mock_tracer_provider = object() - options = ClientOptions(tracer_provider=mock_tracer_provider) +def test_apply_otel_capabilities_to_channel_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + mock_channel = mock.Mock() + + result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) + assert result is mock_channel + + +def test_apply_otel_capabilities_to_channel_enabled_otel_missing(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + # Simulate OTel not being installed by blocking imports + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + mock_channel = mock.Mock() + result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) + assert result is mock_channel + + +def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( @@ -73,22 +88,27 @@ def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) - assert interceptor is mock_interceptor - mock_otel_grpc.client_interceptor.assert_called_once_with( - tracer_provider=mock_tracer_provider - ) + result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) + + assert result is mock_intercepted_channel + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) + mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) -def test_get_otel_grpc_interceptor_enabled_via_dict_config(monkeypatch): - # Tracing enabled via dict config +def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): + # Tracing enabled via config (tracer_provider is set) mock_tracer_provider = object() - options = {"tracer_provider": mock_tracer_provider} + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( @@ -98,8 +118,10 @@ def test_get_otel_grpc_interceptor_enabled_via_dict_config(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) - assert interceptor is mock_interceptor + result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel, client_options=options) + + assert result is mock_intercepted_channel mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider ) + mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) From 85d4784e611f8fdb1e993014fdd9677f10dc792b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 09:14:44 -0400 Subject: [PATCH 07/30] style(api-core): remove decorative emoji from comment --- packages/google-api-core/google/api_core/_otel_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index afd25640fe42..4299f4c33db4 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -89,7 +89,7 @@ def apply_otel_capabilities_to_channel( interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) - # 🎯 We use OTel's own compatible applier! + # We use OTel's own compatible applier to avoid standard gRPC TypeError. return otel_grpc.intercept_channel(channel, interceptor) except ImportError: # Failed open if OTel is not installed but feature was requested. From 7a894d8145f5c73973f05b7fe30db856b2d38fb2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 09:50:58 -0400 Subject: [PATCH 08/30] style(api-core): format code with ruff and fix flake8 --- .../google-api-core/google/api_core/_otel_helpers.py | 3 ++- .../google-api-core/tests/unit/test_otel_helpers.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 4299f4c33db4..0e8a633532ae 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -43,7 +43,8 @@ def is_otel_capabilities_enabled( if is_tracing_enabled: try: - import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] # noqa: F401 + return True except ImportError: pass diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index f093a36fdef8..1a84f66ed523 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -92,7 +92,9 @@ def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): assert result is mock_intercepted_channel mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) - mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_interceptor + ) def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): @@ -118,10 +120,14 @@ def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel, client_options=options) + result = _otel_helpers.apply_otel_capabilities_to_channel( + mock_channel, client_options=options + ) assert result is mock_intercepted_channel mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider ) - mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_interceptor + ) From ed18bac674df74efff2d7a2d88ae9d6390dcd2a0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 10:18:33 -0400 Subject: [PATCH 09/30] test(api-core): add coverage for dict config in _otel_helpers --- .../tests/unit/test_otel_helpers.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 1a84f66ed523..80e46bfd9313 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -131,3 +131,39 @@ def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): mock_otel_grpc.intercept_channel.assert_called_once_with( mock_channel, mock_interceptor ) + + +def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch): + # Tracing enabled via dict config + mock_tracer_provider = object() + options = {"tracer_provider": mock_tracer_provider} + + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + result = _otel_helpers.apply_otel_capabilities_to_channel( + mock_channel, client_options=options + ) + + assert result is mock_intercepted_channel + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_interceptor + ) From 0f0e6e74094274d982b5b40bbd269f7c1b303fdb Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 12:37:38 -0400 Subject: [PATCH 10/30] refactor(api-core): remove redundant checks from apply_otel_capabilities_to_channel --- .../google/api_core/_otel_helpers.py | 43 ++++++------------- .../tests/unit/test_otel_helpers.py | 20 --------- 2 files changed, 13 insertions(+), 50 deletions(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 0e8a633532ae..90a320cbbfe7 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -55,45 +55,28 @@ def is_otel_capabilities_enabled( def apply_otel_capabilities_to_channel( channel: Any, client_options: Optional[ClientOptions | dict[str, Any]] = None, - env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", ) -> Any: - """Applies OTel capabilities (like tracing) to the channel if enabled and installed. + """Applies OTel capabilities (like tracing) to the channel. - This helper centralizes the logic for checking environment variables, - programmatic configuration, and applying OTel interceptors directly to the - channel using specialized OTel wrappers (avoiding standard gRPC TypeError). + Precondition: This function assumes `is_otel_capabilities_enabled` has already + been called and returned `True`. Args: channel: The raw gRPC channel to wrap. client_options: The client options object or dictionary. - env_var: The environment variable to check for enablement. Returns: - Any: The intercepted channel if OTel is enabled and applied, - otherwise the original channel. + Any: The intercepted channel. """ - is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( - env_var=env_var, - feature_key="tracer_provider", - configuration=client_options, - ) + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - if is_tracing_enabled: - try: - import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - - tracer_provider = None - if isinstance(client_options, dict): - tracer_provider = client_options.get("tracer_provider") - elif client_options is not None: - tracer_provider = getattr(client_options, "tracer_provider", None) + tracer_provider = None + if isinstance(client_options, dict): + tracer_provider = client_options.get("tracer_provider") + elif client_options is not None: + tracer_provider = getattr(client_options, "tracer_provider", None) - interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) - - # We use OTel's own compatible applier to avoid standard gRPC TypeError. - return otel_grpc.intercept_channel(channel, interceptor) - except ImportError: - # Failed open if OTel is not installed but feature was requested. - pass + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) - return channel + # We use OTel's own compatible applier to avoid standard gRPC TypeError. + return otel_grpc.intercept_channel(channel, interceptor) diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 80e46bfd9313..c885225c99b6 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -49,27 +49,7 @@ def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): assert _otel_helpers.is_otel_capabilities_enabled() -def test_apply_otel_capabilities_to_channel_disabled(monkeypatch): - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") - mock_channel = mock.Mock() - - result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) - assert result is mock_channel - - -def test_apply_otel_capabilities_to_channel_enabled_otel_missing(monkeypatch): - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - # Simulate OTel not being installed by blocking imports - monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) - - mock_channel = mock.Mock() - result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) - assert result is mock_channel - - def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - mock_channel = mock.Mock() mock_intercepted_channel = mock.Mock() From bac7f7d61a61d3db459a4e4c345334c3e9bed7ea Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Mon, 24 Aug 2026 13:40:39 -0400 Subject: [PATCH 11/30] elaborate in a comment on apply_* role and usage. --- packages/google-api-core/google/api_core/_otel_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 90a320cbbfe7..49b52d391139 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -59,7 +59,8 @@ def apply_otel_capabilities_to_channel( """Applies OTel capabilities (like tracing) to the channel. Precondition: This function assumes `is_otel_capabilities_enabled` has already - been called and returned `True`. + been called and returned `True`, i.e. in the Client. At this time + this function is not intended to be standalone. Args: channel: The raw gRPC channel to wrap. From f9caa92e9a7f3d167a968ba8e9a19ed0cfedabe6 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 07:43:06 -0400 Subject: [PATCH 12/30] refactor(api-core): rename helpers to _observability.py and improve docs --- .../{_otel_helpers.py => _observability.py} | 12 +++++++++--- .../google/api_core/client_options.py | 3 ++- ...{test_otel_helpers.py => test_observability.py} | 14 +++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) rename packages/google-api-core/google/api_core/{_otel_helpers.py => _observability.py} (87%) rename packages/google-api-core/tests/unit/{test_otel_helpers.py => test_observability.py} (91%) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_observability.py similarity index 87% rename from packages/google-api-core/google/api_core/_otel_helpers.py rename to packages/google-api-core/google/api_core/_observability.py index 49b52d391139..a36df8b39599 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -21,6 +21,8 @@ from google.api_core import _feature_gating_helpers from google.api_core.client_options import ClientOptions +_TRACER_PROVIDER = "tracer_provider" + def is_otel_capabilities_enabled( client_options: Optional[ClientOptions | dict[str, Any]] = None, @@ -37,7 +39,7 @@ def is_otel_capabilities_enabled( """ is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( env_var=env_var, - feature_key="tracer_provider", + feature_key=_TRACER_PROVIDER, configuration=client_options, ) @@ -68,14 +70,18 @@ def apply_otel_capabilities_to_channel( Returns: Any: The intercepted channel. + + Raises: + ImportError: If OpenTelemetry packages are not installed and this function + is called directly (bypassing the precondition). """ import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] tracer_provider = None if isinstance(client_options, dict): - tracer_provider = client_options.get("tracer_provider") + tracer_provider = client_options.get(_TRACER_PROVIDER) elif client_options is not None: - tracer_provider = getattr(client_options, "tracer_provider", None) + tracer_provider = getattr(client_options, _TRACER_PROVIDER, None) interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) diff --git a/packages/google-api-core/google/api_core/client_options.py b/packages/google-api-core/google/api_core/client_options.py index ccf731fe6d8c..80d0fa034a9f 100644 --- a/packages/google-api-core/google/api_core/client_options.py +++ b/packages/google-api-core/google/api_core/client_options.py @@ -99,7 +99,8 @@ class ClientOptions(object): then `api_endpoint` is used as the service endpoint. If `api_endpoint` is not specified, the format will be `{service}.{universe_domain}`. tracer_provider (Optional[object]): The OpenTelemetry tracer provider to use - for tracing. If not set, the global tracer provider will be used. + for tracing in supported libraries. If not set, the global tracer provider + will be used. Raises: ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source`` diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_observability.py similarity index 91% rename from packages/google-api-core/tests/unit/test_otel_helpers.py rename to packages/google-api-core/tests/unit/test_observability.py index c885225c99b6..fc63023aadcd 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -15,13 +15,13 @@ import sys from unittest import mock -from google.api_core import _otel_helpers +from google.api_core import _observability from google.api_core.client_options import ClientOptions def test_is_otel_capabilities_enabled_disabled(monkeypatch): monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") - assert not _otel_helpers.is_otel_capabilities_enabled() + assert not _observability.is_otel_capabilities_enabled() def test_is_otel_capabilities_enabled_otel_missing(monkeypatch): @@ -29,7 +29,7 @@ def test_is_otel_capabilities_enabled_otel_missing(monkeypatch): # Simulate OTel not being installed by blocking imports monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) - assert not _otel_helpers.is_otel_capabilities_enabled() + assert not _observability.is_otel_capabilities_enabled() def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): @@ -46,7 +46,7 @@ def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - assert _otel_helpers.is_otel_capabilities_enabled() + assert _observability.is_otel_capabilities_enabled() def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): @@ -68,7 +68,7 @@ def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _otel_helpers.apply_otel_capabilities_to_channel(mock_channel) + result = _observability.apply_otel_capabilities_to_channel(mock_channel) assert result is mock_intercepted_channel mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) @@ -100,7 +100,7 @@ def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _otel_helpers.apply_otel_capabilities_to_channel( + result = _observability.apply_otel_capabilities_to_channel( mock_channel, client_options=options ) @@ -136,7 +136,7 @@ def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch) sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _otel_helpers.apply_otel_capabilities_to_channel( + result = _observability.apply_otel_capabilities_to_channel( mock_channel, client_options=options ) From 8caf428c9b1856daf87f5d971583ade01a90daff Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 08:05:02 -0400 Subject: [PATCH 13/30] chore(api-core): upgrade tracer_provider type hints to string references --- .../google-api-core/google/api_core/client_options.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/google-api-core/google/api_core/client_options.py b/packages/google-api-core/google/api_core/client_options.py index 80d0fa034a9f..be64ae525bc1 100644 --- a/packages/google-api-core/google/api_core/client_options.py +++ b/packages/google-api-core/google/api_core/client_options.py @@ -48,9 +48,13 @@ def get_client_cert(): """ +import typing import warnings from typing import Callable, Mapping, Optional, Sequence, Tuple +if typing.TYPE_CHECKING: + import opentelemetry.trace + from google.api_core import general_helpers @@ -98,7 +102,7 @@ class ClientOptions(object): `googleapis.com`. If both `api_endpoint` and `universe_domain` are set, then `api_endpoint` is used as the service endpoint. If `api_endpoint` is not specified, the format will be `{service}.{universe_domain}`. - tracer_provider (Optional[object]): The OpenTelemetry tracer provider to use + tracer_provider (Optional["opentelemetry.trace.TracerProvider"]): The OpenTelemetry tracer provider to use for tracing in supported libraries. If not set, the global tracer provider will be used. @@ -120,7 +124,7 @@ def __init__( api_key: Optional[str] = None, api_audience: Optional[str] = None, universe_domain: Optional[str] = None, - tracer_provider: Optional[object] = None, + tracer_provider: Optional["opentelemetry.trace.TracerProvider"] = None, ): if credentials_file is not None: warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) From 7c8e58aab16aa0fd06e13fb833635569debc59b0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 08:23:16 -0400 Subject: [PATCH 14/30] chore: add opentelemetry to mypy ignores --- mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy.ini b/mypy.ini index 19b239414608..c1bd1280ab4a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -82,6 +82,9 @@ ignore_missing_imports = True ignore_missing_imports = True +[mypy-opentelemetry.*] +ignore_missing_imports = True + # ============================================================================== # PACKAGE-SPECIFIC OVERRIDES & EXCEPTIONS # ============================================================================== From 053fde9dea1a904c0901238cd078876f03f28b4a Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 08:29:50 -0400 Subject: [PATCH 15/30] chore: add explanatory comment to mypy opentelemetry ignore --- mypy.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mypy.ini b/mypy.ini index c1bd1280ab4a..ee6c090f7212 100644 --- a/mypy.ini +++ b/mypy.ini @@ -82,6 +82,8 @@ ignore_missing_imports = True ignore_missing_imports = True +# OpenTelemetry is an optional dependency and may not be installed in all test +# environments (e.g. to verify core functionality works without it). [mypy-opentelemetry.*] ignore_missing_imports = True From f96333a729274865b2612f2f5abc3927d9f5c07a Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 11:57:25 -0400 Subject: [PATCH 16/30] chore(api-core): add opentelemetry dependencies and constraints --- packages/google-api-core/pyproject.toml | 7 +++++++ packages/google-api-core/testing/constraints-3.10.txt | 3 +++ .../testing/constraints-async-rest-3.10.txt | 3 +++ 3 files changed, 13 insertions(+) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 09038e43a9f3..9bff8ce994cb 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "proto-plus >= 1.26.1, < 2.0.0", "google-auth >= 2.14.1, < 3.0.0", "requests >= 2.33.0, < 3.0.0", + "opentelemetry-api >= 1.44.0, < 2.0.0", ] dynamic = ["version"] @@ -64,6 +65,12 @@ grpc = [ "grpcio-status >= 1.59.0, < 2.0.0", "grpcio-status >= 1.75.1, < 2.0.0; python_version >= '3.14'", ] +tracing = [ + "opentelemetry-instrumentation-grpc >= 0.65b0, < 1.0.0", +] +testing = [ + "opentelemetry-sdk >= 1.44.0, < 2.0.0", +] [tool.setuptools.dynamic] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 5fb51afb6c56..c6a0e6ef4930 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,3 +12,6 @@ requests==2.33.0 grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 +opentelemetry-api==1.44.0 +opentelemetry-instrumentation-grpc==0.65b0 +opentelemetry-sdk==1.44.0 diff --git a/packages/google-api-core/testing/constraints-async-rest-3.10.txt b/packages/google-api-core/testing/constraints-async-rest-3.10.txt index d94635253d59..5f33ca6752ea 100644 --- a/packages/google-api-core/testing/constraints-async-rest-3.10.txt +++ b/packages/google-api-core/testing/constraints-async-rest-3.10.txt @@ -13,3 +13,6 @@ grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 aiohttp==3.13.4 +opentelemetry-api==1.44.0 +opentelemetry-instrumentation-grpc==0.65b0 +opentelemetry-sdk==1.44.0 From 1b60ad516192ae0f55ea71403f5c0f87c3bcb90b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 08:10:13 -0400 Subject: [PATCH 17/30] feat(secretmanager): inject OTel interceptor explicitly in gRPC transport --- .../services/secret_manager_service/client.py | 49 +++++++++++++------ .../secret_manager_service/transports/grpc.py | 11 ++++- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index ff26bddcc57d..fea3ebfcf210 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,17 +35,16 @@ ) import google.protobuf +from google.api_core import _feature_gating_helpers, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions -from google.api_core import gapic_v1 from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.exceptions import MutualTLSChannelError # type: ignore from google.auth.transport import mtls # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore -from google.oauth2 import service_account # type: ignore - from google.cloud.secretmanager_v1 import gapic_version as package_version +from google.oauth2 import service_account # type: ignore try: OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None] @@ -68,7 +67,6 @@ import google.protobuf.field_mask_pb2 as field_mask_pb2 # type: ignore import google.protobuf.timestamp_pb2 as timestamp_pb2 # type: ignore from google.cloud.location import locations_pb2 # type: ignore - from google.cloud.secretmanager_v1.services.secret_manager_service import pagers from google.cloud.secretmanager_v1.types import resources, service @@ -745,18 +743,39 @@ def __init__( if isinstance(transport, str) or transport is None else cast(Callable[..., SecretManagerServiceTransport], transport) ) + # Resolve interceptors for gRPC + interceptors = None + if transport_init is SecretManagerServiceGrpcTransport: + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=self._client_options, + ) + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + tracer_provider = getattr(self._client_options, "tracer_provider", None) + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + interceptors = [interceptor] + except ImportError: + pass + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + } + if interceptors is not None: + transport_kwargs["interceptors"] = interceptors + + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor( diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py index 51530553e705..fa9f87d2d81e 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py @@ -30,9 +30,8 @@ from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.cloud.location import locations_pb2 # type: ignore -from google.protobuf.json_format import MessageToJson - from google.cloud.secretmanager_v1.types import resources, service +from google.protobuf.json_format import MessageToJson from .base import DEFAULT_CLIENT_INFO, SecretManagerServiceTransport @@ -148,6 +147,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[Sequence[object]] = None, ) -> None: """Instantiate the transport. @@ -198,6 +198,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[grpc.ClientInterceptor]]): + Additional interceptors to be injected into the gRPC channel pipeline. + These are executed in order. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -274,6 +277,10 @@ def __init__( ], ) + if interceptors: + for i in interceptors: + self._grpc_channel = grpc.intercept_channel(self._grpc_channel, i) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel( self._grpc_channel, self._interceptor From 0b7c3c2ef69a6f971281d31333d6b429b9d07515 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 21 Aug 2026 07:54:23 -0400 Subject: [PATCH 18/30] feat(secretmanager): refactor OTel interceptor injection to use centralized helper --- .../services/secret_manager_service/client.py | 18 ++------ .../test_secret_manager_service.py | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index fea3ebfcf210..e1de597276f4 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,7 +35,7 @@ ) import google.protobuf -from google.api_core import _feature_gating_helpers, gapic_v1 +from google.api_core import _feature_gating_helpers, _otel_helpers, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import retry as retries @@ -746,19 +746,11 @@ def __init__( # Resolve interceptors for gRPC interceptors = None if transport_init is SecretManagerServiceGrpcTransport: - is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( - env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", - feature_key="tracer_provider", - configuration=self._client_options, + otel_interceptor = _otel_helpers.get_otel_grpc_interceptor( + self._client_options ) - if is_tracing_enabled: - try: - import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - tracer_provider = getattr(self._client_options, "tracer_provider", None) - interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) - interceptors = [interceptor] - except ImportError: - pass + if otel_interceptor: + interceptors = [otel_interceptor] # initialize with the provided callable or the passed in class transport_kwargs = { diff --git a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py index 722ac1109a06..83be4cdc9e40 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -61,8 +61,6 @@ from google.auth import credentials as ga_credentials from google.auth.exceptions import MutualTLSChannelError from google.cloud.location import locations_pb2 -from google.oauth2 import service_account - from google.cloud.secretmanager_v1.services.secret_manager_service import ( SecretManagerServiceAsyncClient, SecretManagerServiceClient, @@ -70,6 +68,7 @@ transports, ) from google.cloud.secretmanager_v1.types import resources, service +from google.oauth2 import service_account CRED_INFO_JSON = { "credential_source": "/path/to/file", @@ -770,6 +769,49 @@ def test_secret_manager_service_client_client_options( ) +def test_secret_manager_service_client_otel_interceptor_injection(): + # Mock the helper to return a sentinel interceptor + mock_interceptor = mock.Mock() + + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.get_otel_grpc_interceptor" + ) as mock_get_interceptor: + mock_get_interceptor.return_value = mock_interceptor + + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None + + client = SecretManagerServiceClient(transport="grpc") + + mock_get_interceptor.assert_called_once() + + called_kwargs = patched_transport_init.call_args.kwargs + assert "interceptors" in called_kwargs + assert called_kwargs["interceptors"] == [mock_interceptor] + + +def test_secret_manager_service_client_otel_interceptor_injection_disabled(): + # The helper does not return an interceptor + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.get_otel_grpc_interceptor" + ) as mock_get_interceptor: + mock_get_interceptor.return_value = None + + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None + + client = SecretManagerServiceClient(transport="grpc") + + mock_get_interceptor.assert_called_once() + + called_kwargs = patched_transport_init.call_args.kwargs + assert "interceptors" not in called_kwargs + + @pytest.mark.parametrize( "client_class,transport_class,transport_name,use_client_cert_env", [ From bedd92e0b310f0a70ef075eae681bf8c82623246 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 08:22:35 -0400 Subject: [PATCH 19/30] feat(otel): implement eager channel wrapping (Variant B) for OTel compatibility --- .../services/secret_manager_service/client.py | 32 +++++--- .../test_secret_manager_service.py | 81 +++++++++++-------- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index e1de597276f4..544aef6e3c80 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -743,15 +743,6 @@ def __init__( if isinstance(transport, str) or transport is None else cast(Callable[..., SecretManagerServiceTransport], transport) ) - # Resolve interceptors for gRPC - interceptors = None - if transport_init is SecretManagerServiceGrpcTransport: - otel_interceptor = _otel_helpers.get_otel_grpc_interceptor( - self._client_options - ) - if otel_interceptor: - interceptors = [otel_interceptor] - # initialize with the provided callable or the passed in class transport_kwargs = { "credentials": credentials, @@ -764,8 +755,27 @@ def __init__( "always_use_jwt_access": True, "api_audience": self._client_options.api_audience, } - if interceptors is not None: - transport_kwargs["interceptors"] = interceptors + + # 🎯 VARIANT B: Eager Channel Creation and Tracing Application + if transport_init is SecretManagerServiceGrpcTransport: + if _otel_helpers.is_otel_capabilities_enabled(self._client_options): + # Eagerly create the channel using the Transport's classmethod + raw_channel = transport_init.create_channel( + self._api_endpoint, + credentials=credentials, + credentials_file=self._client_options.credentials_file, + scopes=self._client_options.scopes, + quota_project_id=self._client_options.quota_project_id, + ) + + # Apply OTel capabilities to the channel + wrapped_channel = _otel_helpers.apply_otel_capabilities_to_channel( + raw_channel, + self._client_options + ) + + # Inject the wrapped channel into transport kwargs + transport_kwargs["channel"] = wrapped_channel self._transport = transport_init(**transport_kwargs) diff --git a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py index 83be4cdc9e40..537a5e662294 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -769,47 +769,60 @@ def test_secret_manager_service_client_client_options( ) -def test_secret_manager_service_client_otel_interceptor_injection(): - # Mock the helper to return a sentinel interceptor - mock_interceptor = mock.Mock() +def test_secret_manager_service_client_otel_eager_channel_injection(): + # Mock the helpers to simulate OTel being enabled + mock_raw_channel = mock.Mock() + mock_wrapped_channel = mock.Mock() with mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.get_otel_grpc_interceptor" - ) as mock_get_interceptor: - mock_get_interceptor.return_value = mock_interceptor - - with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "__init__" - ) as patched_transport_init: - patched_transport_init.return_value = None - - client = SecretManagerServiceClient(transport="grpc") - - mock_get_interceptor.assert_called_once() - - called_kwargs = patched_transport_init.call_args.kwargs - assert "interceptors" in called_kwargs - assert called_kwargs["interceptors"] == [mock_interceptor] - - -def test_secret_manager_service_client_otel_interceptor_injection_disabled(): - # The helper does not return an interceptor + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", + return_value=True + ) as mock_is_enabled: + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.apply_otel_capabilities_to_channel", + return_value=mock_wrapped_channel + ) as mock_apply_otel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "create_channel", + return_value=mock_raw_channel + ) as mock_create_channel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None + + client = SecretManagerServiceClient(transport="grpc") + + mock_is_enabled.assert_called_once() + mock_create_channel.assert_called_once() + mock_apply_otel.assert_called_once_with(mock_raw_channel, mock.ANY) + + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" in called_kwargs + assert called_kwargs["channel"] == mock_wrapped_channel + + +def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): + # Simulate OTel being disabled with mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.get_otel_grpc_interceptor" - ) as mock_get_interceptor: - mock_get_interceptor.return_value = None - + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", + return_value=False + ) as mock_is_enabled: with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "__init__" - ) as patched_transport_init: - patched_transport_init.return_value = None + transports.SecretManagerServiceGrpcTransport, "create_channel" + ) as mock_create_channel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None - client = SecretManagerServiceClient(transport="grpc") + client = SecretManagerServiceClient(transport="grpc") - mock_get_interceptor.assert_called_once() + mock_is_enabled.assert_called_once() + mock_create_channel.assert_not_called() - called_kwargs = patched_transport_init.call_args.kwargs - assert "interceptors" not in called_kwargs + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" not in called_kwargs @pytest.mark.parametrize( From ddaca010e98bee97d7f0eb7c2d284c8132204a53 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Mon, 24 Aug 2026 14:58:31 -0400 Subject: [PATCH 20/30] Updates comment. --- .../secretmanager_v1/services/secret_manager_service/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 544aef6e3c80..82c63519165a 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -756,7 +756,7 @@ def __init__( "api_audience": self._client_options.api_audience, } - # 🎯 VARIANT B: Eager Channel Creation and Tracing Application + # Eager channel creation and application of otel capabilitites if transport_init is SecretManagerServiceGrpcTransport: if _otel_helpers.is_otel_capabilities_enabled(self._client_options): # Eagerly create the channel using the Transport's classmethod From 2013686f32bccc9b759a9f3d367428d98ca55f44 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 24 Aug 2026 15:05:18 -0400 Subject: [PATCH 21/30] fix(secretmanager): align interceptor typehints and explain gRPC specific logic --- .../services/secret_manager_service/client.py | 7 +++++-- .../secret_manager_service/transports/grpc.py | 2 +- .../secretmanager_v1/test_secret_manager_service.py | 11 ++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 82c63519165a..3f296412e920 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -756,6 +756,10 @@ def __init__( "api_audience": self._client_options.api_audience, } + # NOTE: Eager channel wrapping is only required for standard gRPC. + # OTel gRPC interceptors use a custom protocol (grpcext) that crashes standard + # grpc.intercept_channel inside the Transport. Other wrappers (like Asyncio/REST) + # do not suffer from this specific type incompatibility. # Eager channel creation and application of otel capabilitites if transport_init is SecretManagerServiceGrpcTransport: if _otel_helpers.is_otel_capabilities_enabled(self._client_options): @@ -770,8 +774,7 @@ def __init__( # Apply OTel capabilities to the channel wrapped_channel = _otel_helpers.apply_otel_capabilities_to_channel( - raw_channel, - self._client_options + raw_channel, self._client_options ) # Inject the wrapped channel into transport kwargs diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py index fa9f87d2d81e..e161aca3a25d 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py @@ -147,7 +147,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, - interceptors: Optional[Sequence[object]] = None, + interceptors: Optional[Sequence[grpc.ClientInterceptor]] = None, ) -> None: """Instantiate the transport. diff --git a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py index 537a5e662294..ccaf17a57eec 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -776,15 +776,16 @@ def test_secret_manager_service_client_otel_eager_channel_injection(): with mock.patch( "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", - return_value=True + return_value=True, ) as mock_is_enabled: with mock.patch( "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.apply_otel_capabilities_to_channel", - return_value=mock_wrapped_channel + return_value=mock_wrapped_channel, ) as mock_apply_otel: with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "create_channel", - return_value=mock_raw_channel + transports.SecretManagerServiceGrpcTransport, + "create_channel", + return_value=mock_raw_channel, ) as mock_create_channel: with mock.patch.object( transports.SecretManagerServiceGrpcTransport, "__init__" @@ -806,7 +807,7 @@ def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): # Simulate OTel being disabled with mock.patch( "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", - return_value=False + return_value=False, ) as mock_is_enabled: with mock.patch.object( transports.SecretManagerServiceGrpcTransport, "create_channel" From 4db7ed4831b942658e93057f926a5ea594d982c1 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Mon, 24 Aug 2026 15:12:13 -0400 Subject: [PATCH 22/30] Add context to the comment --- .../services/secret_manager_service/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 3f296412e920..e21c1b963409 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -756,11 +756,13 @@ def __init__( "api_audience": self._client_options.api_audience, } - # NOTE: Eager channel wrapping is only required for standard gRPC. + # NOTE: Eager channel wrapping is only required to accommodate standard gRPC. # OTel gRPC interceptors use a custom protocol (grpcext) that crashes standard - # grpc.intercept_channel inside the Transport. Other wrappers (like Asyncio/REST) + # grpc.intercept_channel() inside the Transport. Other wrappers (like Asyncio/REST) # do not suffer from this specific type incompatibility. - # Eager channel creation and application of otel capabilitites + # We create a raw_channel, apply otel capabilities to the channel and + # pass the channel to transport_init. When the Transport finds an existing + # channel it will use that, otherwise it will create one lazily. if transport_init is SecretManagerServiceGrpcTransport: if _otel_helpers.is_otel_capabilities_enabled(self._client_options): # Eagerly create the channel using the Transport's classmethod From 1b042a26a41a74809eb2334579c3161f14867e15 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 25 Aug 2026 10:29:25 -0400 Subject: [PATCH 23/30] refactor(secretmanager): align with new _observability helpers and sharpen type hints --- .../services/secret_manager_service/client.py | 8 +- .../secret_manager_service/transports/grpc.py | 11 ++- .../google-cloud-secret-manager/noxfile.py | 8 ++ .../test_secret_manager_service.py | 84 ++++++++++--------- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index e21c1b963409..e17acb90857f 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,7 +35,7 @@ ) import google.protobuf -from google.api_core import _feature_gating_helpers, _otel_helpers, gapic_v1 +from google.api_core import _feature_gating_helpers, _observability, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import retry as retries @@ -764,9 +764,9 @@ def __init__( # pass the channel to transport_init. When the Transport finds an existing # channel it will use that, otherwise it will create one lazily. if transport_init is SecretManagerServiceGrpcTransport: - if _otel_helpers.is_otel_capabilities_enabled(self._client_options): + if _observability.is_otel_capabilities_enabled(self._client_options): # Eagerly create the channel using the Transport's classmethod - raw_channel = transport_init.create_channel( + raw_channel = SecretManagerServiceGrpcTransport.create_channel( self._api_endpoint, credentials=credentials, credentials_file=self._client_options.credentials_file, @@ -775,7 +775,7 @@ def __init__( ) # Apply OTel capabilities to the channel - wrapped_channel = _otel_helpers.apply_otel_capabilities_to_channel( + wrapped_channel = _observability.apply_otel_capabilities_to_channel( raw_channel, self._client_options ) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py index e161aca3a25d..6c454dfa0948 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py @@ -44,6 +44,13 @@ _LOGGER = std_logging.getLogger(__name__) +ClientInterceptor = Union[ + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +] + class _LoggingClientInterceptor(grpc.UnaryUnaryClientInterceptor): # pragma: NO COVER def intercept_unary_unary(self, continuation, client_call_details, request): @@ -147,7 +154,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, - interceptors: Optional[Sequence[grpc.ClientInterceptor]] = None, + interceptors: Optional[Sequence[ClientInterceptor]] = None, ) -> None: """Instantiate the transport. @@ -198,7 +205,7 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. - interceptors (Optional[Sequence[grpc.ClientInterceptor]]): + interceptors (Optional[Sequence[ClientInterceptor]]): Additional interceptors to be injected into the gRPC channel pipeline. These are executed in order. diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..68d01578433b 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -367,6 +367,14 @@ def system(session): ) +@nox.session(python=DEFAULT_PYTHON_VERSION) +def integration(session): + """Run integration tests with OpenTelemetry.""" + session.install("-e", ".") + session.install("pytest", "opentelemetry-sdk", "opentelemetry-instrumentation-grpc") + session.run("py.test", "tests/integration", *session.posargs) + + @nox.session(python=DEFAULT_PYTHON_VERSION) def cover(session): """Run the final coverage report. diff --git a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py index ccaf17a57eec..c820c28c1077 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -774,56 +774,64 @@ def test_secret_manager_service_client_otel_eager_channel_injection(): mock_raw_channel = mock.Mock() mock_wrapped_channel = mock.Mock() - with mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", - return_value=True, - ) as mock_is_enabled: - with mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.apply_otel_capabilities_to_channel", + # We use parenthesized context managers (Python 3.10+) to flatten the mocks. + # This prevents deep indentation while ensuring all mocks are active during + # Client instantiation. + with ( + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled", + return_value=True, + ) as mock_is_enabled, + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.apply_otel_capabilities_to_channel", return_value=mock_wrapped_channel, - ) as mock_apply_otel: - with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, - "create_channel", - return_value=mock_raw_channel, - ) as mock_create_channel: - with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "__init__" - ) as patched_transport_init: - patched_transport_init.return_value = None + ) as mock_apply_otel, + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, + "create_channel", + return_value=mock_raw_channel, + ) as mock_create_channel, + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SecretManagerServiceClient(transport="grpc") - client = SecretManagerServiceClient(transport="grpc") - mock_is_enabled.assert_called_once() - mock_create_channel.assert_called_once() - mock_apply_otel.assert_called_once_with(mock_raw_channel, mock.ANY) + mock_is_enabled.assert_called_once() + mock_create_channel.assert_called_once() + mock_apply_otel.assert_called_once_with(mock_raw_channel, mock.ANY) - called_kwargs = patched_transport_init.call_args.kwargs - assert "channel" in called_kwargs - assert called_kwargs["channel"] == mock_wrapped_channel + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" in called_kwargs + assert called_kwargs["channel"] == mock_wrapped_channel def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): # Simulate OTel being disabled - with mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", - return_value=False, - ) as mock_is_enabled: - with mock.patch.object( + # We use parenthesized context managers (Python 3.10+) to flatten the mocks. + # This prevents deep indentation while ensuring all mocks are active during + # Client instantiation. + with ( + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled", + return_value=False, + ) as mock_is_enabled, + mock.patch.object( transports.SecretManagerServiceGrpcTransport, "create_channel" - ) as mock_create_channel: - with mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "__init__" - ) as patched_transport_init: - patched_transport_init.return_value = None + ) as mock_create_channel, + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SecretManagerServiceClient(transport="grpc") - client = SecretManagerServiceClient(transport="grpc") + mock_is_enabled.assert_called_once() + mock_create_channel.assert_not_called() - mock_is_enabled.assert_called_once() - mock_create_channel.assert_not_called() + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" not in called_kwargs - called_kwargs = patched_transport_init.call_args.kwargs - assert "channel" not in called_kwargs @pytest.mark.parametrize( From 5593437c1cc9ba8460f26c42b0b003d98e6066ca Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Wed, 26 Aug 2026 09:59:33 -0400 Subject: [PATCH 24/30] update comment for clarity --- .../secretmanager_v1/services/secret_manager_service/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index e17acb90857f..92581dfa0357 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -762,7 +762,8 @@ def __init__( # do not suffer from this specific type incompatibility. # We create a raw_channel, apply otel capabilities to the channel and # pass the channel to transport_init. When the Transport finds an existing - # channel it will use that, otherwise it will create one lazily. + # channel such as the one we pass, it will use it, otherwise it + # will create one lazily. if transport_init is SecretManagerServiceGrpcTransport: if _observability.is_otel_capabilities_enabled(self._client_options): # Eagerly create the channel using the Transport's classmethod From 1e6691d4a3a14b08a0f72e8479b1c9213cedddec Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Wed, 26 Aug 2026 10:01:36 -0400 Subject: [PATCH 25/30] Update comment for clarity --- .../secretmanager_v1/services/secret_manager_service/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 92581dfa0357..70e0c23be825 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -780,7 +780,8 @@ def __init__( raw_channel, self._client_options ) - # Inject the wrapped channel into transport kwargs + # Inject the wrapped channel into transport kwargs so that the + # Transport can find an use it. transport_kwargs["channel"] = wrapped_channel self._transport = transport_init(**transport_kwargs) From 1cb9d2c13f8ca508b0b83bed1d7f89e1f91e7121 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Wed, 26 Aug 2026 10:02:09 -0400 Subject: [PATCH 26/30] fixes spelling error --- .../secretmanager_v1/services/secret_manager_service/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 70e0c23be825..bfed3bceda6d 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -781,7 +781,7 @@ def __init__( ) # Inject the wrapped channel into transport kwargs so that the - # Transport can find an use it. + # Transport can find and use it. transport_kwargs["channel"] = wrapped_channel self._transport = transport_init(**transport_kwargs) From 6b28e5bd18094cfd94eb888019852df9b5a8c3b5 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 26 Aug 2026 11:01:28 -0400 Subject: [PATCH 27/30] test(secretmanager): refactor otel channel injection unit tests with shared setup helper --- .../test_secret_manager_service.py | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py index c820c28c1077..bd9b56756f51 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -14,6 +14,7 @@ # limitations under the License. # import asyncio +import contextlib import json import math import os @@ -769,8 +770,9 @@ def test_secret_manager_service_client_client_options( ) -def test_secret_manager_service_client_otel_eager_channel_injection(): - # Mock the helpers to simulate OTel being enabled +@contextlib.contextmanager +def _mock_otel_client_setup(enabled: bool): + """Context manager to isolate client OTel wiring from network and real capabilities.""" mock_raw_channel = mock.Mock() mock_wrapped_channel = mock.Mock() @@ -778,60 +780,69 @@ def test_secret_manager_service_client_otel_eager_channel_injection(): # This prevents deep indentation while ensuring all mocks are active during # Client instantiation. with ( + # 1. Mock feature gating checks (simulate OTel enabled/disabled state) mock.patch( "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled", - return_value=True, + return_value=enabled, ) as mock_is_enabled, + # 2. Mock OTel channel wrapper helper mock.patch( "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.apply_otel_capabilities_to_channel", return_value=mock_wrapped_channel, ) as mock_apply_otel, + # 3. Mock underlying gRPC channel creation to avoid network calls mock.patch.object( transports.SecretManagerServiceGrpcTransport, "create_channel", return_value=mock_raw_channel, ) as mock_create_channel, + # 4. Stub transport init to intercept constructor args without execution mock.patch.object( transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None ) as patched_transport_init, ): + yield { + "is_enabled": mock_is_enabled, + "apply_otel": mock_apply_otel, + "create_channel": mock_create_channel, + "transport_init": patched_transport_init, + "raw_channel": mock_raw_channel, + "wrapped_channel": mock_wrapped_channel, + } + + +def test_secret_manager_service_client_otel_eager_channel_injection(): + with _mock_otel_client_setup(enabled=True) as mocks: client = SecretManagerServiceClient(transport="grpc") + # Verify feature flag was checked + mocks["is_enabled"].assert_called_once() - mock_is_enabled.assert_called_once() - mock_create_channel.assert_called_once() - mock_apply_otel.assert_called_once_with(mock_raw_channel, mock.ANY) + # Verify raw channel creation was triggered + mocks["create_channel"].assert_called_once() - called_kwargs = patched_transport_init.call_args.kwargs + # Verify raw channel was passed to OTel wrapper + mocks["apply_otel"].assert_called_once_with(mocks["raw_channel"], mock.ANY) + + # Verify wrapped channel was forwarded into transport kwargs + called_kwargs = mocks["transport_init"].call_args.kwargs assert "channel" in called_kwargs - assert called_kwargs["channel"] == mock_wrapped_channel + assert called_kwargs["channel"] == mocks["wrapped_channel"] def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): - # Simulate OTel being disabled - # We use parenthesized context managers (Python 3.10+) to flatten the mocks. - # This prevents deep indentation while ensuring all mocks are active during - # Client instantiation. - with ( - mock.patch( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled", - return_value=False, - ) as mock_is_enabled, - mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "create_channel" - ) as mock_create_channel, - mock.patch.object( - transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None - ) as patched_transport_init, - ): + with _mock_otel_client_setup(enabled=False) as mocks: client = SecretManagerServiceClient(transport="grpc") - mock_is_enabled.assert_called_once() - mock_create_channel.assert_not_called() + # Verify feature flag was checked + mocks["is_enabled"].assert_called_once() - called_kwargs = patched_transport_init.call_args.kwargs - assert "channel" not in called_kwargs + # Verify channel creation was skipped when disabled + mocks["create_channel"].assert_not_called() + # Verify channel was omitted from transport kwargs + called_kwargs = mocks["transport_init"].call_args.kwargs + assert "channel" not in called_kwargs @pytest.mark.parametrize( From a217227912b429e64820fa001a0fb4f1aab574e9 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 26 Aug 2026 11:17:42 -0400 Subject: [PATCH 28/30] chore(secretmanager): remove integration nox session --- packages/google-cloud-secret-manager/noxfile.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 68d01578433b..3943f9aea974 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -367,14 +367,6 @@ def system(session): ) -@nox.session(python=DEFAULT_PYTHON_VERSION) -def integration(session): - """Run integration tests with OpenTelemetry.""" - session.install("-e", ".") - session.install("pytest", "opentelemetry-sdk", "opentelemetry-instrumentation-grpc") - session.run("py.test", "tests/integration", *session.posargs) - - @nox.session(python=DEFAULT_PYTHON_VERSION) def cover(session): """Run the final coverage report. From 6b1ee00821b0930b00f5fb9b705a9d9dba3c1c3e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 26 Aug 2026 13:27:34 -0400 Subject: [PATCH 29/30] chore(secretmanager): add google-api-core[tracing,testing] to unit test local dependencies --- packages/google-cloud-secret-manager/noxfile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..edc95c3289fb 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -71,7 +71,9 @@ "pytest-asyncio", ] UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = [] -UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [] +UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [ + "../google-api-core[tracing,testing]", +] UNIT_TEST_DEPENDENCIES: List[str] = [] UNIT_TEST_EXTRAS: List[str] = [] UNIT_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = {} From e6ba4acd3f263599f811e533d451b661cc8aa9c5 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 26 Aug 2026 14:13:10 -0400 Subject: [PATCH 30/30] fix(secretmanager): update google-api-core constraint and setup requirement to 2.35.0 --- packages/google-cloud-secret-manager/setup.py | 2 +- .../google-cloud-secret-manager/testing/constraints-3.10.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/google-cloud-secret-manager/setup.py b/packages/google-cloud-secret-manager/setup.py index 69abc90c64cb..551996a45c94 100644 --- a/packages/google-cloud-secret-manager/setup.py +++ b/packages/google-cloud-secret-manager/setup.py @@ -44,7 +44,7 @@ release_status = "Development Status :: 5 - Production/Stable" dependencies = [ - "google-api-core[grpc] >= 2.25.0, <3.0.0", + "google-api-core[grpc] >= 2.35.0, <3.0.0", # Exclude incompatible versions of `google-auth` # See https://github.com/googleapis/google-cloud-python/issues/12364 "google-auth >= 2.14.1, <3.0.0,!=2.24.0,!=2.25.0", diff --git a/packages/google-cloud-secret-manager/testing/constraints-3.10.txt b/packages/google-cloud-secret-manager/testing/constraints-3.10.txt index 0ce4b3d6e6f5..9e261ce48fe5 100644 --- a/packages/google-cloud-secret-manager/testing/constraints-3.10.txt +++ b/packages/google-cloud-secret-manager/testing/constraints-3.10.txt @@ -4,7 +4,7 @@ # pinning their versions to their lower bounds. # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 -google-api-core==2.25.0 +google-api-core==2.35.0 google-auth==2.14.1 grpcio==1.59.0 proto-plus==1.26.1