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..e21c1b963409 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,17 +35,16 @@ ) import google.protobuf +from google.api_core import _feature_gating_helpers, _otel_helpers, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions -from google.api_core import 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,45 @@ 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 it will use that, otherwise it will create one lazily. + if transport_init is SecretManagerServiceGrpcTransport: + if _otel_helpers.is_otel_capabilities_enabled(self._client_options): + # Eagerly create the channel using the Transport's classmethod + raw_channel = transport_init.create_channel( + self._api_endpoint, + credentials=credentials, + credentials_file=self._client_options.credentials_file, + scopes=self._client_options.scopes, + quota_project_id=self._client_options.quota_project_id, + ) + + # Apply OTel capabilities to the channel + wrapped_channel = _otel_helpers.apply_otel_capabilities_to_channel( + raw_channel, self._client_options + ) + + # Inject the wrapped channel into transport kwargs + transport_kwargs["channel"] = wrapped_channel + + self._transport = transport_init(**transport_kwargs) 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..e161aca3a25d 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/transports/grpc.py @@ -30,9 +30,8 @@ from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.cloud.location import locations_pb2 # type: ignore -from google.protobuf.json_format import MessageToJson - from google.cloud.secretmanager_v1.types import resources, service +from google.protobuf.json_format import MessageToJson from .base import DEFAULT_CLIENT_INFO, SecretManagerServiceTransport @@ -148,6 +147,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[Sequence[grpc.ClientInterceptor]] = None, ) -> None: """Instantiate the transport. @@ -198,6 +198,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[grpc.ClientInterceptor]]): + Additional interceptors to be injected into the gRPC channel pipeline. + These are executed in order. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -274,6 +277,10 @@ def __init__( ], ) + if interceptors: + for i in interceptors: + self._grpc_channel = grpc.intercept_channel(self._grpc_channel, i) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel( self._grpc_channel, self._interceptor 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..ccaf17a57eec 100644 --- a/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py +++ b/packages/google-cloud-secret-manager/tests/unit/gapic/secretmanager_v1/test_secret_manager_service.py @@ -61,8 +61,6 @@ from google.auth import credentials as ga_credentials from google.auth.exceptions import MutualTLSChannelError from google.cloud.location import locations_pb2 -from google.oauth2 import service_account - from google.cloud.secretmanager_v1.services.secret_manager_service import ( SecretManagerServiceAsyncClient, SecretManagerServiceClient, @@ -70,6 +68,7 @@ transports, ) from google.cloud.secretmanager_v1.types import resources, service +from google.oauth2 import service_account CRED_INFO_JSON = { "credential_source": "/path/to/file", @@ -770,6 +769,63 @@ def test_secret_manager_service_client_client_options( ) +def test_secret_manager_service_client_otel_eager_channel_injection(): + # Mock the helpers to simulate OTel being enabled + mock_raw_channel = mock.Mock() + mock_wrapped_channel = mock.Mock() + + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", + return_value=True, + ) as mock_is_enabled: + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.apply_otel_capabilities_to_channel", + return_value=mock_wrapped_channel, + ) as mock_apply_otel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, + "create_channel", + return_value=mock_raw_channel, + ) as mock_create_channel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None + + client = SecretManagerServiceClient(transport="grpc") + + mock_is_enabled.assert_called_once() + mock_create_channel.assert_called_once() + mock_apply_otel.assert_called_once_with(mock_raw_channel, mock.ANY) + + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" in called_kwargs + assert called_kwargs["channel"] == mock_wrapped_channel + + +def test_secret_manager_service_client_otel_eager_channel_injection_disabled(): + # Simulate OTel being disabled + with mock.patch( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._otel_helpers.is_otel_capabilities_enabled", + return_value=False, + ) as mock_is_enabled: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "create_channel" + ) as mock_create_channel: + with mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__" + ) as patched_transport_init: + patched_transport_init.return_value = None + + client = SecretManagerServiceClient(transport="grpc") + + mock_is_enabled.assert_called_once() + mock_create_channel.assert_not_called() + + called_kwargs = patched_transport_init.call_args.kwargs + assert "channel" not in called_kwargs + + @pytest.mark.parametrize( "client_class,transport_class,transport_name,use_client_cert_env", [