From f2ecbc0dd6af41dcda63c8978b7a16773248ae12 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:22:20 -0400 Subject: [PATCH 01/16] feat(api_core): Add tracer_provider to ClientOptions --- packages/google-api-core/google/api_core/client_options.py | 4 ++++ packages/google-api-core/tests/unit/test_client_options.py | 2 +- 2 files changed, 5 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..724a720c82b0 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 TracerProvider to use + for tracing. If not set, the global tracer provider is 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..632fd0740772 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 @@ -162,6 +161,7 @@ def test_repr(): "scopes", "api_key", "api_audience", + "tracer_provider", ] ) options = client_options.ClientOptions(api_endpoint="foo.googleapis.com") From f8e5f4cb4d94c53e972f2bbb1a05bfbec45e153d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:22:24 -0400 Subject: [PATCH 02/16] feat(api_core): Plumb tracer_provider in grpc_helpers.create_channel --- .../google/api_core/grpc_helpers.py | 31 +++++++++++++++++-- packages/google-api-core/pyproject.toml | 3 ++ .../testing/constraints-3.10.txt | 1 + .../testing/constraints-async-rest-3.10.txt | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/google-api-core/google/api_core/grpc_helpers.py b/packages/google-api-core/google/api_core/grpc_helpers.py index 263079e7d1f7..67b73d031418 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -25,8 +25,7 @@ import google.auth.transport.requests import google.protobuf import grpc - -from google.api_core import exceptions, general_helpers +from google.api_core import _feature_gating_helpers, exceptions, general_helpers # The list of gRPC Callable interfaces that return iterators. _STREAM_WRAP_CLASSES = (grpc.UnaryStreamMultiCallable, grpc.StreamStreamMultiCallable) @@ -384,10 +383,36 @@ def create_channel( if attempt_direct_path: target = _modify_target_for_direct_path(target) - return grpc.secure_channel( + configuration = kwargs.pop("configuration", None) + + channel = grpc.secure_channel( target, composite_credentials, compression=compression, **kwargs ) + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=configuration, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + tracer_provider = None + if configuration is not None: + if isinstance(configuration, dict): + tracer_provider = configuration.get("tracer_provider") + else: + tracer_provider = getattr(configuration, "tracer_provider", None) + + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + channel = otel_grpc.intercept_channel(channel, interceptor) + except ImportError: + # If OpenTelemetry gRPC instrumentation is missing, this should simply NOOP and fail open rather than failing import. + pass + + return channel + def _modify_target_for_direct_path(target: str) -> str: """ diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 18113cb4de74..8c0a9558c28f 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.27.0, < 2.0.0", ] dynamic = ["version"] @@ -91,4 +92,6 @@ filterwarnings = [ "ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning", # Remove once https://github.com/grpc/grpc/issues/35086 is fixed (and version newer than 1.60.0 is published) "ignore:There is no current event loop:DeprecationWarning", + # Ignore external OpenTelemetry/importlib.metadata SelectableGroups warning + "ignore:.*SelectableGroups dict interface is deprecated:DeprecationWarning", ] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 5fb51afb6c56..4cb9760152c6 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,3 +12,4 @@ requests==2.33.0 grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 +opentelemetry-api==1.27.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..bd2beec5f247 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,4 @@ grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 aiohttp==3.13.4 +opentelemetry-api==1.27.0 From 49a88ff925f30ef6db9d081085fb179da57fa30c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:22:27 -0400 Subject: [PATCH 03/16] fix(api_core): Prevent TypeError in grpc_helpers_async.create_channel --- .../google/api_core/grpc_helpers_async.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/google-api-core/google/api_core/grpc_helpers_async.py b/packages/google-api-core/google/api_core/grpc_helpers_async.py index d1f897901e7a..0a6c9a96814c 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers_async.py +++ b/packages/google-api-core/google/api_core/grpc_helpers_async.py @@ -24,9 +24,8 @@ from typing import AsyncGenerator, Generic, Iterator, Optional, TypeVar import grpc -from grpc import aio - from google.api_core import exceptions, general_helpers, grpc_helpers +from grpc import aio # denotes the proto response type for grpc calls P = TypeVar("P") @@ -303,6 +302,15 @@ def create_channel( if attempt_direct_path: target = grpc_helpers._modify_target_for_direct_path(target) + # NOTE: 'configuration' is popped to prevent a TypeError. + # Generated async transports (like those in secretmanager) pass 'configuration' + # down to this helper via **kwargs to support tracing in sync transports. + # However, 'aio.secure_channel' does not recognize this parameter and will + # crash if it is passed through. + # Async gRPC tracing is deferred to a future phase/PR, so we simply discard + # this parameter for now to ensure generated async code doesn't fail at runtime. + kwargs.pop("configuration", None) + return aio.secure_channel( target, composite_credentials, compression=compression, **kwargs ) From 73f91fe527f510e65018a743fa1789d72dc4752d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:22:30 -0400 Subject: [PATCH 04/16] test(api_core): Add OTel gRPC helpers tests --- .../tests/unit/test_grpc_helpers_otel.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/google-api-core/tests/unit/test_grpc_helpers_otel.py diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py new file mode 100644 index 000000000000..577b08dce48e --- /dev/null +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -0,0 +1,159 @@ +# 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. + +"""Tests for OpenTelemetry gRPC interceptor integration in google-api-core.""" + +import sys +import types +from unittest import mock + +import pytest + +try: + from google.api_core import grpc_helpers + + HAS_GRPC_HELPERS = True +except ImportError: + HAS_GRPC_HELPERS = False + + +@pytest.fixture +def mock_otel_grpc(monkeypatch): + """Fixture to mock OpenTelemetry gRPC hierarchy.""" + 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.side_effect = lambda ch, inc: f"wrapped_{ch}" + + modules = { + "opentelemetry": mock_otel, + "opentelemetry.instrumentation": mock_otel.instrumentation, + "opentelemetry.instrumentation.grpc": mock_otel_grpc, + } + + for name, mod in modules.items(): + monkeypatch.setitem(sys.modules, name, mod) + + return mock_otel_grpc + + +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_otel_installed_and_enabled(monkeypatch, mock_otel_grpc): + """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" + + # Enable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + # Mock grpc.secure_channel + mock_channel = "raw_channel" + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: + # We need to mock credentials setup to avoid external calls + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify OTel interceptor was fetched and channel was wrapped + mock_otel_grpc.client_interceptor.assert_called_once() + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_otel_grpc.client_interceptor.return_value + ) + + # Verify returned channel is the wrapped one + assert channel == f"wrapped_{mock_channel}" + + +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_otel_installed_but_disabled(monkeypatch, mock_otel_grpc): + """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" + + # Disable tracing (or leave unset, default should be false/disabled) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + mock_channel = "raw_channel" + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify OTel was NOT used + mock_otel_grpc.intercept_channel.assert_not_called() + + # Verify returned channel is the raw one + assert channel == mock_channel + + +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_otel_not_installed_fails_open(monkeypatch): + """Verify that create_channel fails open if OTel is not installed, even if enabled.""" + + # Simulate missing module + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + # Enable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_channel = "raw_channel" + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): + # This should NOT raise ImportError + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify returned channel is the raw one + assert channel == mock_channel + + +@pytest.mark.parametrize( + "config_factory", + [ + lambda tp: {"tracer_provider": tp}, + lambda tp: types.SimpleNamespace(tracer_provider=tp), + ], + ids=["dict", "object"], +) +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_with_custom_tracer_provider(monkeypatch, mock_otel_grpc, config_factory): + """Verify that create_channel passes custom tracer_provider to OTel interceptor.""" + + mock_tracer_provider = mock.Mock() + config = config_factory(mock_tracer_provider) + + mock_channel = "raw_channel" + with mock.patch("grpc.secure_channel", return_value=mock_channel): + with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + grpc_helpers.create_channel("localhost:1234", configuration=config) + + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=mock_tracer_provider) From ca1559bbf344f3b2f41088c1342d190d7ed08ee0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:22:35 -0400 Subject: [PATCH 05/16] chore(api_core): Clean up imports in tests --- .../google-api-core/tests/asyncio/test_grpc_helpers_async.py | 1 - packages/google-api-core/tests/unit/test_grpc_helpers.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py index dcb09f18fea2..bdc8b80ba8c8 100644 --- a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py +++ b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py @@ -33,7 +33,6 @@ import google.auth.credentials - from google.api_core import exceptions, grpc_helpers_async diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers.py b/packages/google-api-core/tests/unit/test_grpc_helpers.py index 69281d58109b..39b533261c71 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -24,9 +24,8 @@ pytest.skip("No GRPC", allow_module_level=True) import google.auth.credentials -from google.longrunning import operations_pb2 - from google.api_core import exceptions, grpc_helpers +from google.longrunning import operations_pb2 def test__patch_callable_name(): From 6c3cca81a16f564a8df3dc643e0fa523d5a5a021 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:53:57 -0400 Subject: [PATCH 06/16] refactor(api_core): Parametrize OTel installation and enablement tests --- .../tests/unit/test_grpc_helpers_otel.py | 99 ++++++------------- 1 file changed, 30 insertions(+), 69 deletions(-) diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py index 577b08dce48e..df95a4edc6a0 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -49,74 +49,28 @@ def mock_otel_grpc(monkeypatch): return mock_otel_grpc +@pytest.mark.parametrize( + "is_otel_installed, tracing_env_var_value, expect_otel_interceptor", + [ + pytest.param(True, "true", True, id="installed_and_enabled"), + pytest.param(True, "false", False, id="installed_but_disabled"), + pytest.param(False, "true", False, id="not_installed_fails_open"), + ], +) @pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") -def test_create_channel_otel_installed_and_enabled(monkeypatch, mock_otel_grpc): - """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" - - # Enable tracing - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - - # Mock grpc.secure_channel - mock_channel = "raw_channel" - with mock.patch( - "grpc.secure_channel", return_value=mock_channel - ) as mock_secure_channel: - # We need to mock credentials setup to avoid external calls - with mock.patch( - "google.api_core.grpc_helpers._create_composite_credentials", - return_value=mock.Mock(), - ): - channel = grpc_helpers.create_channel("localhost:1234") - - # Verify raw channel was created - mock_secure_channel.assert_called_once() - - # Verify OTel interceptor was fetched and channel was wrapped - mock_otel_grpc.client_interceptor.assert_called_once() - mock_otel_grpc.intercept_channel.assert_called_once_with( - mock_channel, mock_otel_grpc.client_interceptor.return_value - ) - - # Verify returned channel is the wrapped one - assert channel == f"wrapped_{mock_channel}" - - -@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") -def test_create_channel_otel_installed_but_disabled(monkeypatch, mock_otel_grpc): - """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" - - # Disable tracing (or leave unset, default should be false/disabled) - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") - - mock_channel = "raw_channel" - with mock.patch( - "grpc.secure_channel", return_value=mock_channel - ) as mock_secure_channel: - with mock.patch( - "google.api_core.grpc_helpers._create_composite_credentials", - return_value=mock.Mock(), - ): - channel = grpc_helpers.create_channel("localhost:1234") - - # Verify raw channel was created - mock_secure_channel.assert_called_once() - - # Verify OTel was NOT used - mock_otel_grpc.intercept_channel.assert_not_called() - - # Verify returned channel is the raw one - assert channel == mock_channel - - -@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") -def test_create_channel_otel_not_installed_fails_open(monkeypatch): - """Verify that create_channel fails open if OTel is not installed, even if enabled.""" +def test_create_channel_otel_combos( + monkeypatch, + mock_otel_grpc, + is_otel_installed, + tracing_env_var_value, + expect_otel_interceptor, +): + """Verify create_channel behavior with various OTel installation and enablement states.""" - # Simulate missing module - monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", tracing_env_var_value) - # Enable tracing - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + if not is_otel_installed: + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) mock_channel = "raw_channel" with mock.patch( @@ -126,14 +80,21 @@ def test_create_channel_otel_not_installed_fails_open(monkeypatch): "google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock(), ): - # This should NOT raise ImportError channel = grpc_helpers.create_channel("localhost:1234") - # Verify raw channel was created + # Always expect raw channel creation mock_secure_channel.assert_called_once() - # Verify returned channel is the raw one - assert channel == mock_channel + if expect_otel_interceptor: + mock_otel_grpc.client_interceptor.assert_called_once() + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_otel_grpc.client_interceptor.return_value + ) + assert channel == f"wrapped_{mock_channel}" + else: + # OTel should NOT have been called + mock_otel_grpc.intercept_channel.assert_not_called() + assert channel == mock_channel @pytest.mark.parametrize( From 8297044fc0599502263491458aaecfa71e232f4d Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Tue, 11 Aug 2026 13:57:20 -0400 Subject: [PATCH 07/16] Update packages/google-api-core/google/api_core/grpc_helpers_async.py --- packages/google-api-core/google/api_core/grpc_helpers_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/grpc_helpers_async.py b/packages/google-api-core/google/api_core/grpc_helpers_async.py index 0a6c9a96814c..9055fa5d81a9 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers_async.py +++ b/packages/google-api-core/google/api_core/grpc_helpers_async.py @@ -303,7 +303,7 @@ def create_channel( target = grpc_helpers._modify_target_for_direct_path(target) # NOTE: 'configuration' is popped to prevent a TypeError. - # Generated async transports (like those in secretmanager) pass 'configuration' + # Generated async transports (like those in google-cloud-* libs) pass 'configuration' # down to this helper via **kwargs to support tracing in sync transports. # However, 'aio.secure_channel' does not recognize this parameter and will # crash if it is passed through. From d019dd329e7713f57f710faddfa2e9b9b73f9413 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Tue, 11 Aug 2026 13:57:28 -0400 Subject: [PATCH 08/16] Update packages/google-api-core/google/api_core/grpc_helpers_async.py --- packages/google-api-core/google/api_core/grpc_helpers_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/grpc_helpers_async.py b/packages/google-api-core/google/api_core/grpc_helpers_async.py index 9055fa5d81a9..c002f6713786 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers_async.py +++ b/packages/google-api-core/google/api_core/grpc_helpers_async.py @@ -305,7 +305,7 @@ def create_channel( # NOTE: 'configuration' is popped to prevent a TypeError. # Generated async transports (like those in google-cloud-* libs) pass 'configuration' # down to this helper via **kwargs to support tracing in sync transports. - # However, 'aio.secure_channel' does not recognize this parameter and will + # However, 'aio.secure_channel' does not recognize this parameter yet and will # crash if it is passed through. # Async gRPC tracing is deferred to a future phase/PR, so we simply discard # this parameter for now to ensure generated async code doesn't fail at runtime. From 182da981d392ed12709b772e818f4dfdb79770d0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 14:28:16 -0400 Subject: [PATCH 09/16] fix(api_core): Use grpc.intercept_channel instead of invalid otel_grpc.intercept_channel --- .../google-api-core/google/api_core/grpc_helpers.py | 2 +- .../tests/unit/test_grpc_helpers_otel.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/google-api-core/google/api_core/grpc_helpers.py b/packages/google-api-core/google/api_core/grpc_helpers.py index 67b73d031418..97574b91a1d4 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -406,7 +406,7 @@ def create_channel( tracer_provider = getattr(configuration, "tracer_provider", None) interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) - channel = otel_grpc.intercept_channel(channel, interceptor) + channel = grpc.intercept_channel(channel, interceptor) except ImportError: # If OpenTelemetry gRPC instrumentation is missing, this should simply NOOP and fail open rather than failing import. pass diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py index df95a4edc6a0..0739e7fdef6f 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -35,7 +35,6 @@ def mock_otel_grpc(monkeypatch): 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.side_effect = lambda ch, inc: f"wrapped_{ch}" modules = { "opentelemetry": mock_otel, @@ -75,7 +74,9 @@ def test_create_channel_otel_combos( mock_channel = "raw_channel" with mock.patch( "grpc.secure_channel", return_value=mock_channel - ) as mock_secure_channel: + ) as mock_secure_channel, mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ) as mock_intercept_channel: with mock.patch( "google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock(), @@ -87,13 +88,13 @@ def test_create_channel_otel_combos( if expect_otel_interceptor: mock_otel_grpc.client_interceptor.assert_called_once() - mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_intercept_channel.assert_called_once_with( mock_channel, mock_otel_grpc.client_interceptor.return_value ) assert channel == f"wrapped_{mock_channel}" else: # OTel should NOT have been called - mock_otel_grpc.intercept_channel.assert_not_called() + mock_intercept_channel.assert_not_called() assert channel == mock_channel @@ -113,7 +114,9 @@ def test_create_channel_with_custom_tracer_provider(monkeypatch, mock_otel_grpc, config = config_factory(mock_tracer_provider) mock_channel = "raw_channel" - with mock.patch("grpc.secure_channel", return_value=mock_channel): + with mock.patch("grpc.secure_channel", return_value=mock_channel), mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ): with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): grpc_helpers.create_channel("localhost:1234", configuration=config) From 061fdaa423b935c3dfc02a0d77e39d3ec6f74c3e Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Thu, 13 Aug 2026 10:15:35 -0400 Subject: [PATCH 10/16] updates comment --- packages/google-api-core/google/api_core/client_options.py | 3 ++- 1 file changed, 2 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 724a720c82b0..3cb430fd17fd 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 TracerProvider to use - for tracing. If not set, the global tracer provider is used. + for tracing. If not set, the global tracer provider is used, if + available. Raises: ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source`` From 9aa3a00d92ad61d91dcef3658651f5697843e27e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 13 Aug 2026 10:18:55 -0400 Subject: [PATCH 11/16] feat(api_core): add tracing extra and improve client options tests --- packages/google-api-core/pyproject.toml | 4 ++++ packages/google-api-core/tests/unit/test_client_options.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 8c0a9558c28f..a6194912f865 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -65,6 +65,10 @@ 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.46b0, < 1.0.0", +] + [tool.setuptools.dynamic] 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 632fd0740772..f46285e8015f 100644 --- a/packages/google-api-core/tests/unit/test_client_options.py +++ b/packages/google-api-core/tests/unit/test_client_options.py @@ -13,6 +13,7 @@ # limitations under the License. from re import match +from unittest import mock import pytest from google.api_core import client_options @@ -41,6 +42,7 @@ def test_constructor(): ], api_audience="foo2.googleapis.com", universe_domain="googleapis.com", + tracer_provider=mock.Mock(), ) assert options.api_endpoint == "foo.googleapis.com" @@ -53,6 +55,7 @@ def test_constructor(): ] assert options.api_audience == "foo2.googleapis.com" assert options.universe_domain == "googleapis.com" + assert options.tracer_provider is not None def test_constructor_with_encrypted_cert_source(): @@ -122,6 +125,7 @@ def test_from_dict(): "https://www.googleapis.com/auth/cloud-platform.read-only", ], "api_audience": "foo2.googleapis.com", + "tracer_provider": mock.Mock(), } ) @@ -135,6 +139,7 @@ def test_from_dict(): "https://www.googleapis.com/auth/cloud-platform.read-only", ] assert options.api_key is None + assert options.tracer_provider is not None assert options.api_audience == "foo2.googleapis.com" From 0309a90fdfdaf02e0d4529666baf2eade81499dd Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 13 Aug 2026 10:54:53 -0400 Subject: [PATCH 12/16] chore(api_core): fix formatting/linter issues --- .../google/api_core/client_options.py | 2 +- .../google/api_core/grpc_helpers.py | 1 + .../tests/unit/test_grpc_helpers_otel.py | 33 +++++++++++++------ 3 files changed, 25 insertions(+), 11 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 3cb430fd17fd..d8d9589f026b 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,7 @@ 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 TracerProvider to use - for tracing. If not set, the global tracer provider is used, if + for tracing. If not set, the global tracer provider is used, if available. Raises: diff --git a/packages/google-api-core/google/api_core/grpc_helpers.py b/packages/google-api-core/google/api_core/grpc_helpers.py index 97574b91a1d4..f7e9552e67ea 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -398,6 +398,7 @@ def create_channel( if is_tracing_enabled: try: import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + tracer_provider = None if configuration is not None: if isinstance(configuration, dict): diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py index 0739e7fdef6f..6f32b2def7dc 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -72,11 +72,14 @@ def test_create_channel_otel_combos( monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) mock_channel = "raw_channel" - with mock.patch( - "grpc.secure_channel", return_value=mock_channel - ) as mock_secure_channel, mock.patch( - "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" - ) as mock_intercept_channel: + with ( + mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel, + mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ) as mock_intercept_channel, + ): with mock.patch( "google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock(), @@ -107,17 +110,27 @@ def test_create_channel_otel_combos( ids=["dict", "object"], ) @pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") -def test_create_channel_with_custom_tracer_provider(monkeypatch, mock_otel_grpc, config_factory): +def test_create_channel_with_custom_tracer_provider( + monkeypatch, mock_otel_grpc, config_factory +): """Verify that create_channel passes custom tracer_provider to OTel interceptor.""" mock_tracer_provider = mock.Mock() config = config_factory(mock_tracer_provider) mock_channel = "raw_channel" - with mock.patch("grpc.secure_channel", return_value=mock_channel), mock.patch( - "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + with ( + mock.patch("grpc.secure_channel", return_value=mock_channel), + mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ), ): - with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): grpc_helpers.create_channel("localhost:1234", configuration=config) - mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=mock_tracer_provider) + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) From 97ded5a64f0cd377b87135e11173e855c784c747 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:23:07 -0400 Subject: [PATCH 13/16] feat(secretmanager): Pass configuration to transports for tracing --- .../services/secret_manager_service/client.py | 59 ++++++++++++++++--- .../secret_manager_service/transports/grpc.py | 5 +- .../transports/grpc_asyncio.py | 5 +- .../secret_manager_service/transports/rest.py | 4 +- 4 files changed, 59 insertions(+), 14 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..f2de98098260 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 @@ -39,13 +39,24 @@ from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries + +try: + from google.api_core import _feature_gating_helpers + HAS_FEATURE_GATING = True +except ImportError: + HAS_FEATURE_GATING = False 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.cloud.secretmanager_v1 import gapic_version as package_version from google.oauth2 import service_account # type: ignore -from google.cloud.secretmanager_v1 import gapic_version as package_version +try: + from opentelemetry import trace + HAS_OTEL = True +except ImportError: + HAS_OTEL = False try: OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None] @@ -59,6 +70,11 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +if HAS_OTEL: + tracer = trace.get_tracer(__name__) +else: + tracer = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) import google.iam.v1.iam_policy_pb2 as iam_policy_pb2 # type: ignore @@ -68,7 +84,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 @@ -756,6 +771,7 @@ def __init__( client_info=client_info, always_use_jwt_access=True, api_audience=self._client_options.api_audience, + configuration=self._client_options, ) if "async" not in str(self._transport): @@ -1871,13 +1887,40 @@ def sample_access_secret_version(): # Validate the universe domain. self._validate_universe_domain() + if HAS_FEATURE_GATING: + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=self._client_options, + ) + else: + is_tracing_enabled = False + # Send the request. - response = rpc( - request, - retry=retry, - timeout=timeout, - metadata=metadata, - ) + if is_tracing_enabled and HAS_OTEL and tracer: + with tracer.start_as_current_span( + "SecretManagerServiceClient.access_secret_version" + ) as span: + span.set_attribute("gcp.secretmanager.secret.name", request.name) + span.set_attribute("gcp.client.service", "secretmanager") + span.set_attribute("gcp.client.version", package_version.__version__) + span.set_attribute("gcp.client.repo", "googleapis/google-cloud-python") + span.set_attribute("gcp.client.artifact", "google-cloud-secret-manager") + response = rpc( + request, + retry=retry, + timeout=timeout, + metadata=metadata, + ) + else: + response = rpc( + request, + retry=retry, + timeout=timeout, + metadata=metadata, + ) + + return response # Done; return the response. return response 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..4b4fba534776 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, + configuration=None, ) -> None: """Instantiate the transport. @@ -272,6 +272,7 @@ def __init__( ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=configuration, ) self._interceptor = _LoggingClientInterceptor() diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc_asyncio.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc_asyncio.py index fb45b3337f4d..f85ccff9b5a5 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc_asyncio.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc_asyncio.py @@ -32,11 +32,10 @@ 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.cloud.secretmanager_v1.types import resources, service from google.protobuf.json_format import MessageToJson from grpc.experimental import aio # type: ignore -from google.cloud.secretmanager_v1.types import resources, service - from .base import DEFAULT_CLIENT_INFO, SecretManagerServiceTransport from .grpc import SecretManagerServiceGrpcTransport @@ -198,6 +197,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, + configuration=None, ) -> None: """Instantiate the transport. @@ -322,6 +322,7 @@ def __init__( ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=configuration, ) self._interceptor = _LoggingClientAIOInterceptor() diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/rest.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/rest.py index e17d21e6fb93..cb8a4ac4c20a 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/rest.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/rest.py @@ -29,11 +29,10 @@ from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.requests import AuthorizedSession # type: ignore from google.cloud.location import locations_pb2 # type: ignore +from google.cloud.secretmanager_v1.types import resources, service from google.protobuf import json_format from requests import __version__ as requests_version -from google.cloud.secretmanager_v1.types import resources, service - from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO from .rest_base import _BaseSecretManagerServiceRestTransport @@ -1071,6 +1070,7 @@ def __init__( url_scheme: str = "https", interceptor: Optional[SecretManagerServiceRestInterceptor] = None, api_audience: Optional[str] = None, + configuration=None, ) -> None: """Instantiate the transport. From 939feebf8e1295adfab5b9eb9d8be308af8465e9 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:23:11 -0400 Subject: [PATCH 14/16] test(secretmanager): Add observability tests and update existing tests --- .../test_secret_manager_service.py | 21 ++- .../tests/unit/test_observability.py | 137 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 packages/google-cloud-secret-manager/tests/unit/test_observability.py 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..63a86e87213a 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", @@ -680,6 +679,7 @@ def test_secret_manager_service_client_client_options( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS_ENDPOINT is @@ -700,6 +700,7 @@ def test_secret_manager_service_client_client_options( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=mock.ANY, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS_ENDPOINT is @@ -718,6 +719,7 @@ def test_secret_manager_service_client_client_options( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=mock.ANY, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS_ENDPOINT has @@ -747,6 +749,7 @@ def test_secret_manager_service_client_client_options( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) # Check the case api_endpoint is provided options = client_options.ClientOptions( @@ -767,6 +770,7 @@ def test_secret_manager_service_client_client_options( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience="https://language.googleapis.com", + configuration=options, ) @@ -859,6 +863,7 @@ def test_secret_manager_service_client_mtls_env_auto( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) # Check the case ADC client cert is provided. Whether client cert is used depends on @@ -896,6 +901,7 @@ def test_secret_manager_service_client_mtls_env_auto( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=mock.ANY, ) # Check the case client_cert_source and ADC client cert are not provided. @@ -921,6 +927,7 @@ def test_secret_manager_service_client_mtls_env_auto( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=mock.ANY, ) @@ -1268,6 +1275,7 @@ def test_secret_manager_service_client_client_options_scopes( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) @@ -1315,6 +1323,7 @@ def test_secret_manager_service_client_client_options_credentials_file( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) @@ -1336,6 +1345,7 @@ def test_secret_manager_service_client_client_options_from_dict(): client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=client._client_options, ) @@ -1377,6 +1387,7 @@ def test_secret_manager_service_client_create_channel_credentials_file( client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) # test that the credentials from file are saved and used as the credentials. @@ -1405,6 +1416,7 @@ def test_secret_manager_service_client_create_channel_credentials_file( ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=options, ) @@ -14683,6 +14695,7 @@ def test_secret_manager_service_transport_create_channel(transport_class, grpc_h ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=None, ) @@ -14717,6 +14730,7 @@ def test_secret_manager_service_grpc_transport_client_cert_source_for_mtls( ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=None, ) # Check if ssl_channel_credentials is not provided, then client_cert_source_for_mtls @@ -14936,6 +14950,7 @@ def test_secret_manager_service_transport_channel_mtls_with_client_cert_source( ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=None, ) assert transport.grpc_channel == mock_grpc_channel assert transport._ssl_channel_credentials == mock_ssl_cred @@ -14983,6 +14998,7 @@ def test_secret_manager_service_transport_channel_mtls_with_adc(transport_class) ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ], + configuration=None, ) assert transport.grpc_channel == mock_grpc_channel @@ -15630,4 +15646,5 @@ def test_api_key_credentials(client_class, transport_class): client_info=transports.base.DEFAULT_CLIENT_INFO, always_use_jwt_access=True, api_audience=None, + configuration=options, ) diff --git a/packages/google-cloud-secret-manager/tests/unit/test_observability.py b/packages/google-cloud-secret-manager/tests/unit/test_observability.py new file mode 100644 index 000000000000..3606256286ba --- /dev/null +++ b/packages/google-cloud-secret-manager/tests/unit/test_observability.py @@ -0,0 +1,137 @@ +# 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. + +from unittest import mock + +import pytest +from google.auth import credentials as ga_credentials +from google.cloud import secretmanager_v1 +from google.cloud.secretmanager_v1.services.secret_manager_service.client import ( + HAS_FEATURE_GATING, + HAS_OTEL, +) +from google.cloud.secretmanager_v1.types import service + +# We use the clean pattern from BigQuery tests +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture +def setup_otel(): + """Fixture to set up in-memory OTel exporting.""" + tracer_provider = TracerProvider() + memory_exporter = InMemorySpanExporter() + span_processor = SimpleSpanProcessor(memory_exporter) + tracer_provider.add_span_processor(span_processor) + + # Override internal global var to inject our provider + orig_trace_provider = trace._TRACER_PROVIDER + trace._TRACER_PROVIDER = tracer_provider + + yield memory_exporter + + trace._TRACER_PROVIDER = orig_trace_provider + + +@pytest.mark.skipif( + not HAS_FEATURE_GATING or not HAS_OTEL, + reason="Requires feature gating and OpenTelemetry", +) +def test_access_secret_version_custom_span(setup_otel, monkeypatch): + """Verify that calling access_secret_version produces a custom T3 span with attributes.""" + + # Enable tracing via env var (assuming this is how we gate it for clients too) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + client = secretmanager_v1.SecretManagerServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + ) + + request = service.AccessSecretVersionRequest( + name="projects/test-project/secrets/test-secret/versions/1" + ) + + # Mock the actual transport call to avoid network calls and focus on wrapping + with mock.patch.object( + type(client.transport.access_secret_version), "__call__" + ) as call: + call.return_value = service.AccessSecretVersionResponse( + name="projects/test-project/secrets/test-secret/versions/1", + ) + + client.access_secret_version(request) + + # Verify spans + exported_spans = setup_otel.get_finished_spans() + + # We expect at least one span (the T3 span) + assert len(exported_spans) >= 1 + + # Find the T3 span (it should be the custom one from the client) + # Naming convention might be "SecretManagerServiceClient.access_secret_version" + t3_span = None + for span in exported_spans: + if "access_secret_version" in span.name: + t3_span = span + break + + assert t3_span is not None, "T3 span not found" + + # Verify custom attributes + attributes = t3_span.attributes + assert ( + attributes.get("gcp.secretmanager.secret.name") + == "projects/test-project/secrets/test-secret/versions/1" + ) + assert attributes.get("gcp.client.service") == "secretmanager" + assert attributes.get("gcp.client.repo") == "googleapis/google-cloud-python" + assert attributes.get("gcp.client.artifact") == "google-cloud-secret-manager" + assert "gcp.client.version" in attributes + + +@pytest.mark.skipif( + not HAS_FEATURE_GATING or not HAS_OTEL, + reason="Requires feature gating and OpenTelemetry", +) +def test_access_secret_version_custom_span_disabled(setup_otel, monkeypatch): + """Verify that calling access_secret_version does NOT produce custom span if disabled.""" + + # Disable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + client = secretmanager_v1.SecretManagerServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + ) + + request = service.AccessSecretVersionRequest( + name="projects/test-project/secrets/test-secret/versions/1" + ) + + with mock.patch.object( + type(client.transport.access_secret_version), "__call__" + ) as call: + call.return_value = service.AccessSecretVersionResponse( + name="projects/test-project/secrets/test-secret/versions/1", + ) + + client.access_secret_version(request) + + exported_spans = setup_otel.get_finished_spans() + + # We expect NO spans + assert len(exported_spans) == 0 + # We might also expect standard attributes like service.name etc, but let's focus on custom ones. From 40f7023a3bfa769b9da0289646f1a5b84a7bfdf9 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 11 Aug 2026 13:23:20 -0400 Subject: [PATCH 15/16] chore(secretmanager): Update noxfile and setup.py for tracing tests --- packages/google-cloud-secret-manager/noxfile.py | 4 +++- packages/google-cloud-secret-manager/setup.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..3541329be9e5 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -41,7 +41,7 @@ DEFAULT_PYTHON_VERSION = "3.14" -PREVIEW_PYTHON_VERSION = "3.15" +PREVIEW_PYTHON_VERSION = "3.14" CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() # Path to the centralized mypy configuration file at the repository root. @@ -69,6 +69,7 @@ "pytest", "pytest-cov", "pytest-asyncio", + "opentelemetry-sdk", ] UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = [] UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [] @@ -642,6 +643,7 @@ def core_deps_from_source(session, protobuf_implementation): session.run( "py.test", "tests/unit", + *session.posargs, env={ "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": protobuf_implementation, }, diff --git a/packages/google-cloud-secret-manager/setup.py b/packages/google-cloud-secret-manager/setup.py index 69abc90c64cb..7bb523af5ce1 100644 --- a/packages/google-cloud-secret-manager/setup.py +++ b/packages/google-cloud-secret-manager/setup.py @@ -53,6 +53,7 @@ "proto-plus >= 1.26.1, <2.0.0", "protobuf >= 6.33.5, < 8.0.0", "grpc-google-iam-v1 >= 0.14.2, <1.0.0", + "opentelemetry-api >= 1.27.0, < 2.0.0", ] extras = {} url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-secret-manager" From fc56170e7ccdf567e3a86402461730d8ea0790a8 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 13 Aug 2026 13:57:29 -0400 Subject: [PATCH 16/16] feat(otel): add error handling to T3 span and refine tests Captures exceptions in access_secret_version on the T3 span and sets error attributes. Refines test fixtures to prevent ProxyTracer leakage. --- .../services/secret_manager_service/client.py | 20 ++++--- ...bility.py => test_client_observability.py} | 54 ++++++++++++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) rename packages/google-cloud-secret-manager/tests/unit/{test_observability.py => test_client_observability.py} (71%) 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 f2de98098260..d8373babc7d7 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 @@ -1906,12 +1906,18 @@ def sample_access_secret_version(): span.set_attribute("gcp.client.version", package_version.__version__) span.set_attribute("gcp.client.repo", "googleapis/google-cloud-python") span.set_attribute("gcp.client.artifact", "google-cloud-secret-manager") - response = rpc( - request, - retry=retry, - timeout=timeout, - metadata=metadata, - ) + try: + response = rpc( + request, + retry=retry, + timeout=timeout, + metadata=metadata, + ) + except Exception as e: + span.set_attribute("exception.type", type(e).__name__) + span.set_attribute("status.message", str(e)) + span.set_status(trace.Status(trace.StatusCode.ERROR)) + raise else: response = rpc( request, @@ -1920,8 +1926,6 @@ def sample_access_secret_version(): metadata=metadata, ) - return response - # Done; return the response. return response diff --git a/packages/google-cloud-secret-manager/tests/unit/test_observability.py b/packages/google-cloud-secret-manager/tests/unit/test_client_observability.py similarity index 71% rename from packages/google-cloud-secret-manager/tests/unit/test_observability.py rename to packages/google-cloud-secret-manager/tests/unit/test_client_observability.py index 3606256286ba..eca39cefc05a 100644 --- a/packages/google-cloud-secret-manager/tests/unit/test_observability.py +++ b/packages/google-cloud-secret-manager/tests/unit/test_client_observability.py @@ -14,6 +14,7 @@ from unittest import mock +import google.cloud.secretmanager_v1.services.secret_manager_service.client as client_module import pytest from google.auth import credentials as ga_credentials from google.cloud import secretmanager_v1 @@ -38,13 +39,21 @@ def setup_otel(): span_processor = SimpleSpanProcessor(memory_exporter) tracer_provider.add_span_processor(span_processor) - # Override internal global var to inject our provider + # Inject our test tracer directly into the client module to bypass ProxyTracer caching issues + orig_tracer = getattr(client_module, "tracer", None) + client_module.tracer = tracer_provider.get_tracer(__name__) + + # Also set global just in case orig_trace_provider = trace._TRACER_PROVIDER trace._TRACER_PROVIDER = tracer_provider yield memory_exporter trace._TRACER_PROVIDER = orig_trace_provider + if orig_tracer is not None: + client_module.tracer = orig_tracer + else: + delattr(client_module, "tracer") @pytest.mark.skipif( @@ -135,3 +144,46 @@ def test_access_secret_version_custom_span_disabled(setup_otel, monkeypatch): # We expect NO spans assert len(exported_spans) == 0 # We might also expect standard attributes like service.name etc, but let's focus on custom ones. + + +@pytest.mark.skipif( + not HAS_FEATURE_GATING or not HAS_OTEL, + reason="Requires feature gating and OpenTelemetry", +) +def test_access_secret_version_custom_span_error(setup_otel, monkeypatch): + """Verify that calling access_secret_version produces custom span with error attributes on failure.""" + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + client = secretmanager_v1.SecretManagerServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + ) + + request = service.AccessSecretVersionRequest( + name="projects/test-project/secrets/test-secret/versions/1" + ) + + error_message = "Permission denied" + with mock.patch.object( + type(client.transport.access_secret_version), "__call__" + ) as call: + call.side_effect = Exception(error_message) + + with pytest.raises(Exception, match=error_message): + client.access_secret_version(request) + + exported_spans = setup_otel.get_finished_spans() + assert len(exported_spans) >= 1 + + t3_span = None + for span in exported_spans: + if "access_secret_version" in span.name: + t3_span = span + break + + assert t3_span is not None, "T3 span not found" + + attributes = t3_span.attributes + assert attributes.get("exception.type") == "Exception" + assert attributes.get("status.message") == error_message + assert t3_span.status.status_code == trace.StatusCode.ERROR