diff --git a/mypy.ini b/mypy.ini index 19b239414608..ee6c090f7212 100644 --- a/mypy.ini +++ b/mypy.ini @@ -82,6 +82,11 @@ 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 + # ============================================================================== # PACKAGE-SPECIFIC OVERRIDES & EXCEPTIONS # ============================================================================== diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py new file mode 100644 index 000000000000..a36df8b39599 --- /dev/null +++ b/packages/google-api-core/google/api_core/_observability.py @@ -0,0 +1,89 @@ +# -*- 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 + +_TRACER_PROVIDER = "tracer_provider" + + +def is_otel_capabilities_enabled( + client_options: Optional[ClientOptions | dict[str, Any]] = None, + env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", +) -> 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] # noqa: F401 + + return True + except ImportError: + pass + + return False + + +def apply_otel_capabilities_to_channel( + channel: Any, + client_options: Optional[ClientOptions | dict[str, Any]] = None, +) -> Any: + """Applies OTel capabilities (like tracing) to the channel. + + Precondition: This function assumes `is_otel_capabilities_enabled` has already + 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. + client_options: The client options object or dictionary. + + 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) + 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) 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..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,6 +102,9 @@ 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["opentelemetry.trace.TracerProvider"]): The OpenTelemetry tracer provider to use + 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`` @@ -117,6 +124,7 @@ def __init__( api_key: Optional[str] = None, api_audience: Optional[str] = None, universe_domain: Optional[str] = None, + tracer_provider: Optional["opentelemetry.trace.TracerProvider"] = None, ): if credentials_file is not None: warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) @@ -136,6 +144,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/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 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") diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py new file mode 100644 index 000000000000..fc63023aadcd --- /dev/null +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -0,0 +1,149 @@ +# 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 + +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 _observability.is_otel_capabilities_enabled() + + +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) + + assert not _observability.is_otel_capabilities_enabled() + + +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 + + 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 + ) + + assert _observability.is_otel_capabilities_enabled() + + +def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): + 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 = _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) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_interceptor + ) + + +def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): + # Tracing enabled via config (tracer_provider is set) + mock_tracer_provider = object() + 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( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + result = _observability.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 + ) + + +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 = _observability.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 + ) 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..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 @@ -35,17 +35,16 @@ ) import google.protobuf +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 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 @@ -746,17 +744,47 @@ def __init__( else cast(Callable[..., SecretManagerServiceTransport], transport) ) # 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, + } + + # 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) + # 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 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 + raw_channel = SecretManagerServiceGrpcTransport.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 = _observability.apply_otel_capabilities_to_channel( + raw_channel, self._client_options + ) + + # Inject the wrapped channel into transport kwargs so that the + # Transport can find and use it. + transport_kwargs["channel"] = wrapped_channel + + 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..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 @@ -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 @@ -45,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): @@ -148,6 +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[ClientInterceptor]] = None, ) -> None: """Instantiate the transport. @@ -198,6 +205,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[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 +284,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 diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..231a455cad37 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -264,15 +264,25 @@ def install_unittest_dependencies(session, *constraints): @nox.session(python=ALL_PYTHON) @nox.parametrize( - "protobuf_implementation", - ["python", "upb"], + ["protobuf_implementation", "install_otel"], + [ + ("python", True), + ("python", False), + ("upb", True), + ("upb", False), + ], ) -def unit(session, protobuf_implementation): +def unit(session, protobuf_implementation, install_otel): # Install all test dependencies, then install this package in-place. constraints_path = str( CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" ) + if install_otel: + session.install("google-api-core[tracing,testing]") + + pytest_path = os.path.join("tests", "unit") + install_unittest_dependencies(session, "-c", constraints_path) # Run py.test against the unit tests. @@ -286,7 +296,7 @@ def unit(session, protobuf_implementation): "--cov-config=.coveragerc", "--cov-report=", "--cov-fail-under=0", - os.path.join("tests", "unit"), + pytest_path, *session.posargs, env={ "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": protobuf_implementation, diff --git a/packages/google-cloud-secret-manager/tests/integration/conftest.py b/packages/google-cloud-secret-manager/tests/integration/conftest.py new file mode 100644 index 000000000000..0e1e107f1e64 --- /dev/null +++ b/packages/google-cloud-secret-manager/tests/integration/conftest.py @@ -0,0 +1,76 @@ +# -*- 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. +# + +from concurrent import futures + +import grpc +import pytest + +try: + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + OTEL_AVAILABLE = True +except ImportError: + OTEL_AVAILABLE = False + + +class GenericHandler(grpc.GenericRpcHandler): + """A generic gRPC handler that catches all methods and returns empty bytes.""" + + def service(self, handler_call_details): + return grpc.unary_unary_rpc_method_handler( + lambda request, context: b"", # Return empty bytes + request_deserializer=lambda x: x, + response_serializer=lambda x: x, + ) + + +@pytest.fixture(scope="module") +def fake_grpc_server(): + """Starts a local generic gRPC server on an open port.""" + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + server.add_generic_rpc_handlers((GenericHandler(),)) + port = server.add_insecure_port("localhost:0") + server.start() + yield f"localhost:{port}" + server.stop(None) + + +@pytest.fixture +def insecure_channel_patch(monkeypatch): + """Mocks grpc.secure_channel to return an insecure channel for testing.""" + + def mock_secure(*args, **kwargs): + return grpc.insecure_channel(args[0]) + + monkeypatch.setattr(grpc, "secure_channel", mock_secure) + + +@pytest.fixture +def otel_in_memory(): + """Sets up in-memory OTel exporting. Skips test if SDK is missing.""" + if not OTEL_AVAILABLE: + pytest.skip("OpenTelemetry SDK not available") + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + return provider, exporter diff --git a/packages/google-cloud-secret-manager/tests/integration/test_otel_tracing.py b/packages/google-cloud-secret-manager/tests/integration/test_otel_tracing.py new file mode 100644 index 000000000000..7f1f830ed524 --- /dev/null +++ b/packages/google-cloud-secret-manager/tests/integration/test_otel_tracing.py @@ -0,0 +1,111 @@ +# -*- 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. +# + +import pytest +from google.auth.credentials import AnonymousCredentials +from google.cloud.secretmanager_v1 import SecretManagerServiceClient + + +@pytest.mark.parametrize( + "method_name, kwargs", + [ + ("list_secrets", {"parent": "projects/test-project"}), + ("get_secret", {"name": "projects/test-project/secrets/test-secret"}), + ], +) +def test_otel_tracing_enabled( + fake_grpc_server, + insecure_channel_patch, + otel_in_memory, + monkeypatch, + method_name, + kwargs, +): + """Verify that calling a method on the client generates spans when tracing is enabled.""" + provider, exporter = otel_in_memory + + # Enable tracing via environment variable + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "True") + + # Instantiate client pointing to fake server + client = SecretManagerServiceClient( + transport="grpc", + client_options={ + "api_endpoint": fake_grpc_server, + "tracer_provider": provider, # Pass custom provider + }, + credentials=AnonymousCredentials(), + ) + + # Call the method + method = getattr(client, method_name) + try: + method(**kwargs) + except Exception as e: + # We might get parsing errors or others if response is not perfectly compatible, + # but we only care about spans for now. + # With our GenericHandler returning b"", it should parse fine for proto3. + print(f"Call raised: {e}") + pass + + + # Verify spans + spans = exporter.get_finished_spans() + assert len(spans) > 0, "No spans recorded!" + + span_names = [s.name for s in spans] + # Expect something like 'SecretManagerService/ListSecrets' + assert any("SecretManagerService" in name for name in span_names) + + +def test_otel_tracing_disabled( + fake_grpc_server, + insecure_channel_patch, + otel_in_memory, + monkeypatch, +): + """Verify that no spans are generated when tracing is explicitly disabled.""" + provider, exporter = otel_in_memory + + # Disable tracing via environment variable + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "False") + + # We do NOT pass tracer_provider in client_options to test Env Var path. + # But we patch trace.get_tracer_provider to return our test provider, + # so IF the interceptor is applied (bug), it will record to our exporter. + from unittest import mock + + from opentelemetry import trace + + with mock.patch.object(trace, "get_tracer_provider", return_value=provider): + client = SecretManagerServiceClient( + transport="grpc", + client_options={ + "api_endpoint": fake_grpc_server, + # "tracer_provider": provider, 🚨 Omit this! + }, + credentials=AnonymousCredentials(), + ) + + try: + client.list_secrets(parent="projects/test-project") + except Exception: + pass + + spans = exporter.get_finished_spans() + assert len(spans) == 0, ( + f"Spans were recorded but tracing should be disabled! Spans: {[s.name for s in spans]}" + ) 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..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 @@ -61,8 +62,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 +69,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 +770,81 @@ def test_secret_manager_service_client_client_options( ) +@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() + + # 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 ( + # 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=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() + + # Verify raw channel creation was triggered + mocks["create_channel"].assert_called_once() + + # 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"] == mocks["wrapped_channel"] + + +def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): + with _mock_otel_client_setup(enabled=False) as mocks: + client = SecretManagerServiceClient(transport="grpc") + + # Verify feature flag was checked + mocks["is_enabled"].assert_called_once() + + # 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( "client_class,transport_class,transport_name,use_client_cert_env", [