Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import functools
import json
import logging as std_logging
import os
Expand All @@ -35,17 +36,16 @@
)

import google.protobuf
from google.api_core import _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]
Expand All @@ -68,7 +68,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

Expand Down Expand Up @@ -746,17 +745,31 @@ 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, bind create_channel_with_otel
# using functools.partial and pass it as the channel factory.
# This preserves lazy channel instantiation inside the Transport and avoids
# duplicating channel initialization arguments here in the client.
if transport_init is SecretManagerServiceGrpcTransport:
if _observability.is_otel_capabilities_enabled(self._client_options):
transport_kwargs["channel"] = functools.partial(
_observability.create_channel_with_otel,
SecretManagerServiceGrpcTransport.create_channel,
client_options=self._client_options,
)

self._transport = transport_init(**transport_kwargs)

if "async" not in str(self._transport):
if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@
import grpc # type: ignore
import proto # type: ignore
from google.api_core import gapic_v1, grpc_helpers
from google.api_core.grpc_helpers import ClientInterceptor
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

Expand Down Expand Up @@ -148,6 +148,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.

Expand Down Expand Up @@ -198,6 +199,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.

@daniel-sanche daniel-sanche Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a comment here, suggesting that we may be able to accept Callable[[Channel], Channel] here to support otel's interceptor

@chalmerlowe chalmerlowe Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Not Recommended

There are a couple of structural reasons why keeping apply_interceptors focused strictly on Sequence[ClientInterceptor] is preferred over also accepting Callable[[Channel], Channel]:

  1. Separation of Concerns:
    Keeping channel as the channel factory parameter (supporting functools.partial) and interceptors as the standard gRPC RPC interceptor parameter gives each argument a single clear responsibility across both sync and async transports.
  2. Wrapper Overhead ($N$ Nested Proxy Channels):
    Calling grpc.intercept_channel(channel, *interceptors) in batch produces a single _InterceptedChannel dispatcher. If we intersperse true interceptors with callables to create (OR modify) channels, things get complicated. We would likely need an intervening step to loop through and examine each item in the interceptor parameter to decide whether it needs calling OR not.
    • Whereas looping sequentially and calling grpc.create_channel over and over creates $N$ nested proxy channel objects, which adds stack frames and wrapper overhead to every RPC.
  3. Incompatibility with Async gRPC (grpc.aio):
    In grpc.aio, channels are immutable once constructed, and there is no post-creation interceptor wrapper. OTel's async interceptor must be passed directly into grpc.aio.secure_channel(..., interceptors=[...]) during channel creation. A Callable[[Channel], Channel] pattern cannot execute in async, which would force sync and async transports to diverge in how they handle interceptors OR necessitate that we inject an intermediary step to handle Channels versus Interceptors.

@daniel-sanche daniel-sanche Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Separation of Concerns:

I'm not sure I understand. keeping channel creation and interceptor application separate is what I'm arguing for. The current design couples the grpc interceptor into the channel creation logic, and decouples the rest, which I find to be a confusing design

  1. Wrapper Overhead

I also don't understand this argument either, and it makes me wonder if you misunderstand what I'm suggesting here.

  • If you're worried about applying interceptors in a loop is less efficient than a single grpc.intercept_channel call, that's already what grpc.intercept_channel does under the hood. Note that each interceptor applied creates a new _Channel. intercept_channel itself is fundamentally a Callable[[Channel], Channel] operation, which is why I think this design works well. We're just extending off of grpc's existing wrapping logic
  • If you're worried about creating more overhead on each rpc, don't be. This applies once when setting up the channel. No matter how we apply the interceptor, the end result is the same after construction
  • if you're worried that adding stacks of interceptors adds overhead, that is true. But that's the cost of adding an interceptor, and we decided that these interceptors are worth adding

Incompatibility with Async gRPC

This is true, but that's because grpc.aio gives us a different API to work with, so I think it makes sense that we also expose a different


Raises:
google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport
Expand Down Expand Up @@ -274,6 +278,10 @@ def __init__(
],
)

self._grpc_channel = grpc_helpers.apply_interceptors(
self._grpc_channel, interceptors
)

self._interceptor = _LoggingClientInterceptor()
self._logged_channel = grpc.intercept_channel(
self._grpc_channel, self._interceptor
Expand Down
4 changes: 3 additions & 1 deletion packages/google-cloud-secret-manager/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"pytest-asyncio",
]
UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = []
UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = []
UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [
"../google-api-core[tracing,testing]",
]
UNIT_TEST_DEPENDENCIES: List[str] = []
UNIT_TEST_EXTRAS: List[str] = []
UNIT_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = {}
Expand Down
2 changes: 1 addition & 1 deletion packages/google-cloud-secret-manager/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
release_status = "Development Status :: 5 - Production/Stable"

dependencies = [
"google-api-core[grpc] >= 2.25.0, <3.0.0",
"google-api-core[grpc] >= 2.35.0, <3.0.0",
# Exclude incompatible versions of `google-auth`
# See https://github.com/googleapis/google-cloud-python/issues/12364
"google-auth >= 2.14.1, <3.0.0,!=2.24.0,!=2.25.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# pinning their versions to their lower bounds.
# For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0",
# then this file should have google-cloud-foo==1.14.0
google-api-core==2.25.0
google-api-core==2.35.0
google-auth==2.14.1
grpcio==1.59.0
proto-plus==1.26.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.
#
import asyncio
import functools
import json
import math
import os
Expand Down Expand Up @@ -61,15 +62,14 @@
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,
pagers,
transports,
)
from google.cloud.secretmanager_v1.types import resources, service
from google.oauth2 import service_account

CRED_INFO_JSON = {
"credential_source": "/path/to/file",
Expand Down Expand Up @@ -770,6 +770,98 @@ 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 detects the feature flag via
_observability.is_otel_capabilities_enabled.
2. The client binds _observability.create_channel_with_otel using
functools.partial with SecretManagerServiceGrpcTransport.create_channel and client_options.
3. The bound channel factory callable is passed into transport kwargs under 'channel',
allowing the Transport to initialize the channel lazily with its own parameters.
"""
with (
mock.patch(
"google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled",
return_value=True,
) as mock_is_enabled,
mock.patch.object(
transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None
) as patched_transport_init,
):
client = SecretManagerServiceClient(transport="grpc")

mock_is_enabled.assert_called_once()
called_kwargs = patched_transport_init.call_args.kwargs
assert "channel" in called_kwargs
channel_factory = called_kwargs["channel"]
assert isinstance(channel_factory, functools.partial)
assert (
channel_factory.func
is google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.create_channel_with_otel
)
assert channel_factory.args == (
transports.SecretManagerServiceGrpcTransport.create_channel,
)
assert channel_factory.keywords == {"client_options": client._client_options}


def test_secret_manager_service_client_otel_channel_injection_disabled():
"""Proves that when OpenTelemetry tracing is disabled:

1. SecretManagerServiceClient checks the feature flag and finds it disabled.
2. Eager channel creation via _observability.create_channel_with_otel is skipped.
3. No 'channel' argument is passed to the transport constructor, preserving lazy
channel initialization in the transport.
"""
with (
mock.patch(
"google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.is_otel_capabilities_enabled",
return_value=False,
) as mock_is_enabled,
mock.patch(
"google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.create_channel_with_otel",
) as mock_create_channel_with_otel,
mock.patch.object(
transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None
) as patched_transport_init,
):
SecretManagerServiceClient(transport="grpc")

mock_is_enabled.assert_called_once()
mock_create_channel_with_otel.assert_not_called()
called_kwargs = patched_transport_init.call_args.kwargs
assert "channel" not in called_kwargs


def test_secret_manager_service_grpc_transport_interceptors():
"""Proves that SecretManagerServiceGrpcTransport accepts custom client interceptors
and invokes grpc_helpers.apply_interceptors to inject them into the underlying
gRPC channel pipeline.
"""
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_interceptors",
return_value=mock_channel,
) as mock_apply_interceptors,
):
transport = transports.SecretManagerServiceGrpcTransport(
interceptors=[mock_interceptor],
)

mock_apply_interceptors.assert_called_once_with(
mock_channel, [mock_interceptor]
)


@pytest.mark.parametrize(
"client_class,transport_class,transport_name,use_client_cert_env",
[
Expand Down
Loading