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 1fce45758368..627c933d38ca 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 @@ -42,9 +42,14 @@ 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 +try: + # mypy: _observability was added in google-api-core 2.35.0; guard for older versions + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: + _observability = None # type: ignore[assignment] + from google.cloud.secretmanager_v1 import gapic_version as package_version from google.cloud.secretmanager_v1._compat import ( get_api_endpoint, @@ -609,17 +614,34 @@ 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, + } + + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + if ( + transport_init is SecretManagerServiceGrpcTransport + and _observability is not None + ): + otel_interceptor = _observability.get_otel_interceptor( + self._client_options + ) + if otel_interceptor is not None: + interceptors = transport_kwargs.get("interceptors", []) + transport_kwargs["interceptors"] = [otel_interceptor] + list( + interceptors + ) + + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor( diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py index 51530553e705..93bf1742b084 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 @@ -17,7 +17,7 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union import google.auth # type: ignore import google.iam.v1.iam_policy_pb2 as iam_policy_pb2 # type: ignore @@ -30,12 +30,19 @@ 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 +# ClientInterceptor type alias for channel interceptors +ClientInterceptor = Union[ + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +] + try: from google.api_core import client_logging # type: ignore @@ -148,6 +155,14 @@ 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[ + Union[ + ClientInterceptor, + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, ) -> None: """Instantiate the transport. @@ -198,6 +213,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[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -274,6 +292,10 @@ def __init__( ], ) + apply_interceptors = getattr(grpc_helpers, "apply_channel_interceptors", None) + if apply_interceptors is not None: + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel( self._grpc_channel, self._interceptor 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 01d17df2d37e..57eb3b22d788 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 functools 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", @@ -476,6 +476,128 @@ def test_secret_manager_service_client_client_options( ) +def test_secret_manager_service_client_otel_channel_injection_enabled(): + """Proves that when OpenTelemetry tracing is enabled: + + 1. SecretManagerServiceClient obtains the channel interceptor via + _observability.get_otel_interceptor passing client_options. + 2. The interceptor is passed into transport kwargs under 'interceptors', + allowing the Transport to apply it via apply_channel_interceptors. + """ + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SecretManagerServiceClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert "interceptors" in called_kwargs + assert called_kwargs["interceptors"] == [mock_interceptor] + + +def test_secret_manager_service_client_otel_channel_injection_disabled(): + """Proves that when OpenTelemetry tracing is disabled: + + 1. SecretManagerServiceClient checks for an OTel interceptor and receives None. + 2. No OTel interceptor is added to the transport constructor kwargs. + """ + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SecretManagerServiceClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + interceptors = called_kwargs.get("interceptors", []) + assert not interceptors + + +def test_secret_manager_service_client_observability_import_error(): + """Proves that when _observability cannot be imported (older google-api-core), + SecretManagerServiceClient instantiates gracefully without error. + """ + with ( + mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability", + None, + ), + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + SecretManagerServiceClient(transport="grpc") + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_secret_manager_service_grpc_transport_interceptors(): + """Proves that SecretManagerServiceGrpcTransport accepts channel interceptors + and invokes grpc_helpers.apply_channel_interceptors to apply them to the underlying + gRPC channel. + """ + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch( + "google.api_core.grpc_helpers.apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transports.SecretManagerServiceGrpcTransport( + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + + +def test_secret_manager_service_grpc_transport_custom_channel_interceptors(): + """Proves that SecretManagerServiceGrpcTransport wraps explicitly passed custom channels + using grpc_helpers.apply_channel_interceptors. + """ + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch( + "google.api_core.grpc_helpers.apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transports.SecretManagerServiceGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + + @pytest.mark.parametrize( "client_class,transport_class,transport_name,use_client_cert_env", [