From 447297e25118c6afa0d2078928f01520c4dd9816 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 05:59:55 -0400 Subject: [PATCH 01/13] feat(api-core): add ClientInterceptor and apply_interceptors helper --- .../google/api_core/grpc_helpers.py | 35 ++++++- .../tests/unit/test_grpc_helpers.py | 94 ++++++++++++++++++- 2 files changed, 125 insertions(+), 4 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..ab944b240198 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -17,7 +17,7 @@ import collections import functools import warnings -from typing import Generic, Iterator, Optional, TypeVar +from typing import Generic, Iterator, Optional, Sequence, TypeVar, Union import google.auth import google.auth.credentials @@ -25,7 +25,6 @@ import google.auth.transport.requests import google.protobuf import grpc - from google.api_core import exceptions, general_helpers # The list of gRPC Callable interfaces that return iterators. @@ -34,6 +33,14 @@ # denotes the proto response type for grpc calls P = TypeVar("P") +# Type alias representing any client-side gRPC interceptor +ClientInterceptor = Union[ + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +] + def _patch_callable_name(callable_): """Fix-up gRPC callable attributes. @@ -419,6 +426,30 @@ def _modify_target_for_direct_path(target: str) -> str: return target +def apply_interceptors( + channel: grpc.Channel, + interceptors: Optional[Sequence[ClientInterceptor]] = None, +) -> grpc.Channel: + """Applies a sequence of interceptors to a gRPC channel. + + The interceptors are applied in the order provided, wrapping the channel + sequentially. + + Args: + channel (grpc.Channel): The channel to intercept. + interceptors (Optional[Sequence[ClientInterceptor]]): An optional sequence + of client interceptors to apply. + + Returns: + grpc.Channel: The intercepted channel, or the original channel if no + interceptors were provided. + """ + if interceptors: + for interceptor in interceptors: + channel = grpc.intercept_channel(channel, interceptor) + return channel + + _MethodCall = collections.namedtuple( "_MethodCall", ("request", "timeout", "metadata", "credentials", "compression") ) 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..677fc15ce2d3 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(): @@ -932,3 +931,94 @@ def test_subscribe_unsubscribe(self): def test_close(self): channel = grpc_helpers.ChannelStub() assert channel.close() is None + + +@pytest.mark.parametrize("falsy_interceptors", [None, [], ()]) +def test_apply_interceptors_passthrough(falsy_interceptors): + """Verify that falsy or empty interceptor sequences return the channel unmodified.""" + mock_channel = mock.Mock() + result = grpc_helpers.apply_interceptors(mock_channel, falsy_interceptors) + assert result is mock_channel + + +@pytest.mark.parametrize("count", [1, 2, 3]) +def test_apply_interceptors_wrapping(count): + """Verify that interceptors are wrapped sequentially in the order provided. + + When given a sequence of N interceptors [i_0, i_1, ..., i_{N-1}], apply_interceptors + must pass the base channel and i_0 to grpc.intercept_channel, then pass the + resulting wrapped channel and i_1 to grpc.intercept_channel, and so on. + This ensures each subsequent interceptor wraps the preceding channel state. + """ + mock_channel = mock.Mock(name="base_channel") + # Generate distinct mock interceptors and the expected wrapped channel returns for each step + interceptors = [mock.Mock(name=f"interceptor_{i}") for i in range(count)] + wrapped_channels = [mock.Mock(name=f"wrapped_channel_{i}") for i in range(count)] + + with mock.patch( + "grpc.intercept_channel", side_effect=wrapped_channels + ) as mock_intercept: + result = grpc_helpers.apply_interceptors(mock_channel, interceptors) + + # The final return value must be the outermost wrapped channel from the final loop iteration + assert result is wrapped_channels[-1] + assert mock_intercept.call_count == count + + # Construct the expected sequential chaining: (base, i_0) -> (wrapped_0, i_1) -> ... + expected_calls = [] + current_channel = mock_channel + for i, interceptor in enumerate(interceptors): + expected_calls.append(mock.call(current_channel, interceptor)) + current_channel = wrapped_channels[i] + + mock_intercept.assert_has_calls(expected_calls) + + +def test_apply_interceptors_execution_order(): + """Verify runtime execution order (onion model) when invoking an RPC on an intercepted channel. + + In gRPC Python, sequential wrapping via grpc.intercept_channel(channel, i) creates an + 'onion' layer where the LAST applied interceptor becomes the OUTSIDE layer. + Therefore, given [i1, i2]: + - i1 wraps the raw channel (innermost layer) + - i2 wraps the result of (channel + i1) (outermost layer) + + During an RPC invocation: + 1. i2 intercepts the call first (request inbound / pre-call) + 2. i2 calls continuation(), which triggers i1 + 3. i1 calls continuation(), which reaches the channel stub / network + 4. i1 post-call logic finishes + 5. i2 post-call logic finishes + """ + execution_order = [] + + class OrderInterceptor(grpc.UnaryUnaryClientInterceptor): + def __init__(self, name): + self.name = name + + def intercept_unary_unary(self, continuation, client_call_details, request): + execution_order.append(f"{self.name}_start") + response = continuation(client_call_details, request) + execution_order.append(f"{self.name}_end") + return response + + i1 = OrderInterceptor("i1") + i2 = OrderInterceptor("i2") + + mock_channel = mock.Mock(spec=grpc.Channel) + mock_callable = mock.Mock(spec=grpc.UnaryUnaryMultiCallable) + mock_call = mock.Mock(spec=grpc.Call) + expected_response = operations_pb2.Operation(name="test_op") + mock_callable.with_call.return_value = (expected_response, mock_call) + mock_channel.unary_unary.return_value = mock_callable + + # Apply interceptors in sequence [i1, i2] + intercepted_channel = grpc_helpers.apply_interceptors(mock_channel, [i1, i2]) + + # Trigger a unary RPC through the intercepted channel + stub = operations_pb2.OperationsStub(intercepted_channel) + response = stub.GetOperation(operations_pb2.GetOperationRequest(name="test_op")) + + assert response.name == "test_op" + # Verify i2 executed as the outer layer surrounding i1 + assert execution_order == ["i2_start", "i1_start", "i1_end", "i2_end"] From 593d049a1dbc01c401477b7d6382b35650828b43 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 06:30:02 -0400 Subject: [PATCH 02/13] fix(api-core): unpack interceptors directly into grpc.intercept_channel --- .../google/api_core/grpc_helpers.py | 7 ++- .../tests/unit/test_grpc_helpers.py | 49 +++++++------------ 2 files changed, 22 insertions(+), 34 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 ab944b240198..bb2a3523d735 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -432,8 +432,8 @@ def apply_interceptors( ) -> grpc.Channel: """Applies a sequence of interceptors to a gRPC channel. - The interceptors are applied in the order provided, wrapping the channel - sequentially. + The interceptors are applied in the order provided, such that the first + interceptor in the sequence is the outermost layer (executes first). Args: channel (grpc.Channel): The channel to intercept. @@ -445,8 +445,7 @@ def apply_interceptors( interceptors were provided. """ if interceptors: - for interceptor in interceptors: - channel = grpc.intercept_channel(channel, interceptor) + return grpc.intercept_channel(channel, *interceptors) return channel 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 677fc15ce2d3..b9fc553e6555 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -943,52 +943,41 @@ def test_apply_interceptors_passthrough(falsy_interceptors): @pytest.mark.parametrize("count", [1, 2, 3]) def test_apply_interceptors_wrapping(count): - """Verify that interceptors are wrapped sequentially in the order provided. + """Verify that interceptors are passed to grpc.intercept_channel in a single call. When given a sequence of N interceptors [i_0, i_1, ..., i_{N-1}], apply_interceptors - must pass the base channel and i_0 to grpc.intercept_channel, then pass the - resulting wrapped channel and i_1 to grpc.intercept_channel, and so on. - This ensures each subsequent interceptor wraps the preceding channel state. + must pass the base channel and all interceptors unpacked (*interceptors) to + grpc.intercept_channel. This creates a single intercepted channel wrapper rather than + multiple nested wrappers. """ mock_channel = mock.Mock(name="base_channel") - # Generate distinct mock interceptors and the expected wrapped channel returns for each step + mock_intercepted = mock.Mock(name="intercepted_channel") interceptors = [mock.Mock(name=f"interceptor_{i}") for i in range(count)] - wrapped_channels = [mock.Mock(name=f"wrapped_channel_{i}") for i in range(count)] with mock.patch( - "grpc.intercept_channel", side_effect=wrapped_channels + "grpc.intercept_channel", return_value=mock_intercepted ) as mock_intercept: result = grpc_helpers.apply_interceptors(mock_channel, interceptors) - # The final return value must be the outermost wrapped channel from the final loop iteration - assert result is wrapped_channels[-1] - assert mock_intercept.call_count == count - - # Construct the expected sequential chaining: (base, i_0) -> (wrapped_0, i_1) -> ... - expected_calls = [] - current_channel = mock_channel - for i, interceptor in enumerate(interceptors): - expected_calls.append(mock.call(current_channel, interceptor)) - current_channel = wrapped_channels[i] - - mock_intercept.assert_has_calls(expected_calls) + assert result is mock_intercepted + mock_intercept.assert_called_once_with(mock_channel, *interceptors) def test_apply_interceptors_execution_order(): """Verify runtime execution order (onion model) when invoking an RPC on an intercepted channel. - In gRPC Python, sequential wrapping via grpc.intercept_channel(channel, i) creates an - 'onion' layer where the LAST applied interceptor becomes the OUTSIDE layer. + In standard gRPC Python, grpc.intercept_channel(channel, *interceptors) processes + the interceptor list such that the first interceptor in the sequence is the outermost layer. Therefore, given [i1, i2]: - - i1 wraps the raw channel (innermost layer) - - i2 wraps the result of (channel + i1) (outermost layer) + - i1 is the outermost layer (executes first on outbound request) + - i2 is the inner layer (executes second on outbound request) During an RPC invocation: - 1. i2 intercepts the call first (request inbound / pre-call) - 2. i2 calls continuation(), which triggers i1 - 3. i1 calls continuation(), which reaches the channel stub / network - 4. i1 post-call logic finishes - 5. i2 post-call logic finishes + 1. i1 intercepts the call first (request inbound / pre-call) + 2. i1 calls continuation(), which triggers i2 + 3. i2 calls continuation(), which reaches the channel stub / network + 4. i2 post-call logic finishes + 5. i1 post-call logic finishes """ execution_order = [] @@ -1020,5 +1009,5 @@ def intercept_unary_unary(self, continuation, client_call_details, request): response = stub.GetOperation(operations_pb2.GetOperationRequest(name="test_op")) assert response.name == "test_op" - # Verify i2 executed as the outer layer surrounding i1 - assert execution_order == ["i2_start", "i1_start", "i1_end", "i2_end"] + # Verify i1 executed as the outer layer surrounding i2 + assert execution_order == ["i1_start", "i2_start", "i2_end", "i1_end"] From 0e0c229d50b39f27052a8e79e3ef074498e03d5e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 09:26:26 -0400 Subject: [PATCH 03/13] docs(api-core): clarify apply_interceptors execution order in docstring --- packages/google-api-core/google/api_core/grpc_helpers.py | 6 +++--- 1 file changed, 3 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 bb2a3523d735..2f0ef9631dd8 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -430,10 +430,10 @@ def apply_interceptors( channel: grpc.Channel, interceptors: Optional[Sequence[ClientInterceptor]] = None, ) -> grpc.Channel: - """Applies a sequence of interceptors to a gRPC channel. + """Applies client interceptors to a gRPC channel. - The interceptors are applied in the order provided, such that the first - interceptor in the sequence is the outermost layer (executes first). + The first interceptor in the sequence is the outermost layer: it + executes first on outbound requests and last on inbound responses. Args: channel (grpc.Channel): The channel to intercept. From fba514fcad642cffff705e7b3063373fe87907b0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 09:45:16 -0400 Subject: [PATCH 04/13] test(api-core): remove redundant execution order test and simplify interceptor unit tests --- .../tests/unit/test_grpc_helpers.py | 55 +------------------ 1 file changed, 2 insertions(+), 53 deletions(-) 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 b9fc553e6555..0dad58217545 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -943,12 +943,11 @@ def test_apply_interceptors_passthrough(falsy_interceptors): @pytest.mark.parametrize("count", [1, 2, 3]) def test_apply_interceptors_wrapping(count): - """Verify that interceptors are passed to grpc.intercept_channel in a single call. + """Verify that interceptors are passed to grpc.intercept_channel unpacked in a single call. When given a sequence of N interceptors [i_0, i_1, ..., i_{N-1}], apply_interceptors must pass the base channel and all interceptors unpacked (*interceptors) to - grpc.intercept_channel. This creates a single intercepted channel wrapper rather than - multiple nested wrappers. + grpc.intercept_channel. """ mock_channel = mock.Mock(name="base_channel") mock_intercepted = mock.Mock(name="intercepted_channel") @@ -961,53 +960,3 @@ def test_apply_interceptors_wrapping(count): assert result is mock_intercepted mock_intercept.assert_called_once_with(mock_channel, *interceptors) - - -def test_apply_interceptors_execution_order(): - """Verify runtime execution order (onion model) when invoking an RPC on an intercepted channel. - - In standard gRPC Python, grpc.intercept_channel(channel, *interceptors) processes - the interceptor list such that the first interceptor in the sequence is the outermost layer. - Therefore, given [i1, i2]: - - i1 is the outermost layer (executes first on outbound request) - - i2 is the inner layer (executes second on outbound request) - - During an RPC invocation: - 1. i1 intercepts the call first (request inbound / pre-call) - 2. i1 calls continuation(), which triggers i2 - 3. i2 calls continuation(), which reaches the channel stub / network - 4. i2 post-call logic finishes - 5. i1 post-call logic finishes - """ - execution_order = [] - - class OrderInterceptor(grpc.UnaryUnaryClientInterceptor): - def __init__(self, name): - self.name = name - - def intercept_unary_unary(self, continuation, client_call_details, request): - execution_order.append(f"{self.name}_start") - response = continuation(client_call_details, request) - execution_order.append(f"{self.name}_end") - return response - - i1 = OrderInterceptor("i1") - i2 = OrderInterceptor("i2") - - mock_channel = mock.Mock(spec=grpc.Channel) - mock_callable = mock.Mock(spec=grpc.UnaryUnaryMultiCallable) - mock_call = mock.Mock(spec=grpc.Call) - expected_response = operations_pb2.Operation(name="test_op") - mock_callable.with_call.return_value = (expected_response, mock_call) - mock_channel.unary_unary.return_value = mock_callable - - # Apply interceptors in sequence [i1, i2] - intercepted_channel = grpc_helpers.apply_interceptors(mock_channel, [i1, i2]) - - # Trigger a unary RPC through the intercepted channel - stub = operations_pb2.OperationsStub(intercepted_channel) - response = stub.GetOperation(operations_pb2.GetOperationRequest(name="test_op")) - - assert response.name == "test_op" - # Verify i1 executed as the outer layer surrounding i2 - assert execution_order == ["i1_start", "i2_start", "i2_end", "i1_end"] From 0a0f0e0741f184b80cd00051513890819c6c44c5 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 10:08:49 -0400 Subject: [PATCH 05/13] test(api-core): align mock variable naming to Option 1 convention --- .../tests/unit/test_grpc_helpers.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 0dad58217545..9e41bab853df 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -936,9 +936,9 @@ def test_close(self): @pytest.mark.parametrize("falsy_interceptors", [None, [], ()]) def test_apply_interceptors_passthrough(falsy_interceptors): """Verify that falsy or empty interceptor sequences return the channel unmodified.""" - mock_channel = mock.Mock() - result = grpc_helpers.apply_interceptors(mock_channel, falsy_interceptors) - assert result is mock_channel + mock_base_channel = mock.Mock(name="base_channel") + result = grpc_helpers.apply_interceptors(mock_base_channel, falsy_interceptors) + assert result is mock_base_channel @pytest.mark.parametrize("count", [1, 2, 3]) @@ -949,14 +949,16 @@ def test_apply_interceptors_wrapping(count): must pass the base channel and all interceptors unpacked (*interceptors) to grpc.intercept_channel. """ - mock_channel = mock.Mock(name="base_channel") - mock_intercepted = mock.Mock(name="intercepted_channel") - interceptors = [mock.Mock(name=f"interceptor_{i}") for i in range(count)] + mock_base_channel = mock.Mock(name="base_channel") + mock_wrapped_channel = mock.Mock(name="wrapped_channel") + mock_interceptors = [mock.Mock(name=f"interceptor_{i}") for i in range(count)] with mock.patch( - "grpc.intercept_channel", return_value=mock_intercepted - ) as mock_intercept: - result = grpc_helpers.apply_interceptors(mock_channel, interceptors) + "grpc.intercept_channel", return_value=mock_wrapped_channel + ) as mock_intercept_channel: + result = grpc_helpers.apply_interceptors(mock_base_channel, mock_interceptors) - assert result is mock_intercepted - mock_intercept.assert_called_once_with(mock_channel, *interceptors) + assert result is mock_wrapped_channel + mock_intercept_channel.assert_called_once_with( + mock_base_channel, *mock_interceptors + ) From f10fd03b0f4fb52f9066aa7d0ab983861e5db0b3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 28 Aug 2026 09:51:22 -0400 Subject: [PATCH 06/13] feat(api-core): update default env var to GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED - Set is_otel_capabilities_enabled default env_var to GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED - Activate fail-fast FeatureGatingError experimental path when tracer_provider is set without env var - Update unit tests to verify experimental gating behavior --- .../google/api_core/_observability.py | 2 +- .../tests/unit/test_observability.py | 43 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index a36df8b39599..b1b71b056658 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -26,7 +26,7 @@ def is_otel_capabilities_enabled( client_options: Optional[ClientOptions | dict[str, Any]] = None, - env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + env_var: str = "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", ) -> bool: """Checks if OTel capabilities are enabled and installed. diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index fc63023aadcd..d39edb806040 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -15,17 +15,19 @@ import sys from unittest import mock +import pytest from google.api_core import _observability +from google.api_core._feature_gating_helpers import FeatureGatingError from google.api_core.client_options import ClientOptions def test_is_otel_capabilities_enabled_disabled(monkeypatch): - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_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") + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") # Simulate OTel not being installed by blocking imports monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) @@ -33,7 +35,7 @@ def test_is_otel_capabilities_enabled_otel_missing(monkeypatch): def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc @@ -49,6 +51,41 @@ def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): assert _observability.is_otel_capabilities_enabled() +def test_is_otel_capabilities_enabled_experimental_requires_env_var(monkeypatch): + """Proves that passing client_options with tracer_provider without the experimental + env var set to 'true' raises FeatureGatingError (Fail Fast). + """ + monkeypatch.delenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", raising=False) + options = ClientOptions(tracer_provider=object()) + + with pytest.raises( + FeatureGatingError, + match="requires GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", + ): + _observability.is_otel_capabilities_enabled(options) + + +def test_is_otel_capabilities_enabled_experimental_enabled_with_config(monkeypatch): + """Proves that when GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED=true and tracer_provider + is supplied via client_options, is_otel_capabilities_enabled returns True. + """ + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_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 + ) + + options = ClientOptions(tracer_provider=object()) + assert _observability.is_otel_capabilities_enabled(options) + + def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): mock_channel = mock.Mock() mock_intercepted_channel = mock.Mock() From a1fb4c64ad38e5218ca4e812cb1c22e596971f9a Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 11:22:06 -0400 Subject: [PATCH 07/13] feat(api-core): add eager channel orchestration for OpenTelemetry - Implement create_channel_with_otel and create_async_channel_with_otel helpers - Deduplicate interceptor instantiation via internal _get_otel_interceptor - Add unit tests in test_observability.py --- .../google/api_core/_observability.py | 87 +++++++-- .../tests/unit/test_observability.py | 175 ++++++++++++++---- 2 files changed, 211 insertions(+), 51 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index b1b71b056658..858451db6ac4 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -16,7 +16,7 @@ """OpenTelemetry helpers for resolving and instantiating interceptors.""" -from typing import Any, Optional +from typing import Any, Callable, Optional, Union from google.api_core import _feature_gating_helpers from google.api_core.client_options import ClientOptions @@ -54,26 +54,19 @@ def is_otel_capabilities_enabled( return False -def apply_otel_capabilities_to_channel( - channel: Any, - client_options: Optional[ClientOptions | dict[str, Any]] = None, +def _get_otel_interceptor( + client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None, + is_async: bool = False, ) -> 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. + """Instantiates a sync or async OpenTelemetry gRPC client interceptor. Args: - channel: The raw gRPC channel to wrap. client_options: The client options object or dictionary. + is_async: If True, returns an async interceptor (`aio_client_interceptor`), + otherwise returns a sync interceptor (`client_interceptor`). Returns: - Any: The intercepted channel. - - Raises: - ImportError: If OpenTelemetry packages are not installed and this function - is called directly (bypassing the precondition). + Any: The instantiated OpenTelemetry client interceptor. """ import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] @@ -83,7 +76,65 @@ def apply_otel_capabilities_to_channel( elif client_options is not None: tracer_provider = getattr(client_options, _TRACER_PROVIDER, None) - interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + if is_async: + return otel_grpc.aio_client_interceptor(tracer_provider=tracer_provider) + return otel_grpc.client_interceptor(tracer_provider=tracer_provider) + + +def create_channel_with_otel( + channel_factory: Callable[..., Any], + client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None, + **channel_kwargs: Any, +) -> Any: + """Creates a gRPC channel using the provided factory and applies OTel capabilities if enabled. + + If OpenTelemetry capabilities are enabled (via environment variable or client_options), + the created raw channel is intercepted with an OpenTelemetry client interceptor. + Otherwise, the raw channel is returned unmodified. + + Args: + channel_factory: A callable (such as a Transport's `create_channel` classmethod) + that instantiates and returns a raw gRPC channel. + client_options: The client options object or dictionary used for feature gating + and extracting the tracer provider. + **channel_kwargs: Keyword arguments forwarded directly to `channel_factory`. + + Returns: + Any: The intercepted or raw gRPC channel. + """ + raw_channel = channel_factory(**channel_kwargs) + if is_otel_capabilities_enabled(client_options): + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + + interceptor = _get_otel_interceptor(client_options, is_async=False) + return otel_grpc.intercept_channel(raw_channel, interceptor) + return raw_channel + + +def create_async_channel_with_otel( + channel_factory: Callable[..., Any], + client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None, + **channel_kwargs: Any, +) -> Any: + """Creates an async gRPC channel using the provided factory with OTel interceptors injected if enabled. + + Because `grpc.aio` channels are immutable after creation, any OpenTelemetry interceptor + must be passed into `channel_factory` during instantiation via the `interceptors` keyword argument. + + Args: + channel_factory: A callable (such as an Async Transport's `create_channel` classmethod) + that instantiates and returns an async gRPC channel. + client_options: The client options object or dictionary used for feature gating + and extracting the tracer provider. + **channel_kwargs: Keyword arguments forwarded directly to `channel_factory`. + + Returns: + Any: The instantiated async gRPC channel. + """ + if is_otel_capabilities_enabled(client_options): + async_interceptor = _get_otel_interceptor(client_options, is_async=True) + interceptors = list(channel_kwargs.pop("interceptors", []) or []) + interceptors.append(async_interceptor) + channel_kwargs["interceptors"] = interceptors - # We use OTel's own compatible applier to avoid standard gRPC TypeError. - return otel_grpc.intercept_channel(channel, interceptor) + return channel_factory(**channel_kwargs) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index d39edb806040..094d201b35f1 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -86,16 +86,57 @@ def test_is_otel_capabilities_enabled_experimental_enabled_with_config(monkeypat assert _observability.is_otel_capabilities_enabled(options) -def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): - mock_channel = mock.Mock() - mock_intercepted_channel = mock.Mock() +def test_get_otel_interceptor_sync_default(monkeypatch): + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + + 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._get_otel_interceptor() + assert result is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) + + +def test_get_otel_interceptor_sync_config(monkeypatch): + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + 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._get_otel_interceptor(client_options=options) + assert result is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + + +def test_get_otel_interceptor_sync_dict_config(monkeypatch): + mock_tracer_provider = object() + options = {"tracer_provider": mock_tracer_provider} + + 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( @@ -105,29 +146,69 @@ def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _observability.apply_otel_capabilities_to_channel(mock_channel) + result = _observability._get_otel_interceptor(client_options=options) + assert result is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + + +def test_get_otel_interceptor_async(monkeypatch): + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_async_interceptor = mock.Mock() + mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor + + 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._get_otel_interceptor(client_options=options, is_async=True) + assert result is mock_async_interceptor + mock_otel_grpc.aio_client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) - 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_create_channel_with_otel_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false") + mock_raw_channel = mock.Mock(name="raw_channel") + mock_channel_factory = mock.Mock(return_value=mock_raw_channel) + + result = _observability.create_channel_with_otel( + mock_channel_factory, + target="example.com:443", + credentials="mock_creds", ) + assert result is mock_raw_channel + mock_channel_factory.assert_called_once_with( + target="example.com:443", credentials="mock_creds" + ) -def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): - # Tracing enabled via config (tracer_provider is set) + +def test_create_channel_with_otel_enabled(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() options = ClientOptions(tracer_provider=mock_tracer_provider) - mock_channel = mock.Mock() - mock_intercepted_channel = mock.Mock() + mock_raw_channel = mock.Mock(name="raw_channel") + mock_wrapped_channel = mock.Mock(name="wrapped_channel") + mock_channel_factory = mock.Mock(return_value=mock_raw_channel) 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 + mock_otel_grpc.intercept_channel.return_value = mock_wrapped_channel monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( @@ -137,33 +218,57 @@ def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _observability.apply_otel_capabilities_to_channel( - mock_channel, client_options=options + result = _observability.create_channel_with_otel( + mock_channel_factory, + client_options=options, + target="example.com:443", + credentials="mock_creds", ) - assert result is mock_intercepted_channel + assert result is mock_wrapped_channel + mock_channel_factory.assert_called_once_with( + target="example.com:443", credentials="mock_creds" + ) 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 + mock_raw_channel, mock_interceptor + ) + + +def test_create_async_channel_with_otel_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false") + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + user_interceptor = mock.Mock(name="user_interceptor") + + result = _observability.create_async_channel_with_otel( + mock_channel_factory, + target="example.com:443", + interceptors=[user_interceptor], + ) + + assert result is mock_async_channel + mock_channel_factory.assert_called_once_with( + target="example.com:443", + interceptors=[user_interceptor], ) -def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch): - # Tracing enabled via dict config +def test_create_async_channel_with_otel_enabled(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() - options = {"tracer_provider": mock_tracer_provider} + options = ClientOptions(tracer_provider=mock_tracer_provider) - mock_channel = mock.Mock() - mock_intercepted_channel = mock.Mock() + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + user_interceptor = mock.Mock(name="user_interceptor") + mock_async_interceptor = mock.Mock(name="otel_async_interceptor") 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 + mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( @@ -173,14 +278,18 @@ def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch) sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc ) - result = _observability.apply_otel_capabilities_to_channel( - mock_channel, client_options=options + result = _observability.create_async_channel_with_otel( + mock_channel_factory, + client_options=options, + target="example.com:443", + interceptors=[user_interceptor], ) - assert result is mock_intercepted_channel - mock_otel_grpc.client_interceptor.assert_called_once_with( + assert result is mock_async_channel + mock_otel_grpc.aio_client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider ) - mock_otel_grpc.intercept_channel.assert_called_once_with( - mock_channel, mock_interceptor + mock_channel_factory.assert_called_once_with( + target="example.com:443", + interceptors=[user_interceptor, mock_async_interceptor], ) From c3b23a96ef0a139959c4cbeeec492176d9ecacc3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 13:11:19 -0400 Subject: [PATCH 08/13] refactor(api-core): simplify async interceptors extraction and expand tests - Use list(channel_kwargs.pop('interceptors', None) or []) in create_async_channel_with_otel - Add unit tests for None and omitted interceptors arguments --- .../google/api_core/_observability.py | 2 +- .../tests/unit/test_observability.py | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 858451db6ac4..d8881d15c601 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -133,7 +133,7 @@ def create_async_channel_with_otel( """ if is_otel_capabilities_enabled(client_options): async_interceptor = _get_otel_interceptor(client_options, is_async=True) - interceptors = list(channel_kwargs.pop("interceptors", []) or []) + interceptors = list(channel_kwargs.pop("interceptors", None) or []) interceptors.append(async_interceptor) channel_kwargs["interceptors"] = interceptors diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 094d201b35f1..edae8c70699d 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -293,3 +293,76 @@ def test_create_async_channel_with_otel_enabled(monkeypatch): target="example.com:443", interceptors=[user_interceptor, mock_async_interceptor], ) + + +def test_create_async_channel_with_otel_none_interceptors(monkeypatch): + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + mock_async_interceptor = mock.Mock(name="otel_async_interceptor") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor + + 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.create_async_channel_with_otel( + mock_channel_factory, + client_options=options, + target="example.com:443", + interceptors=None, + ) + + assert result is mock_async_channel + mock_otel_grpc.aio_client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + mock_channel_factory.assert_called_once_with( + target="example.com:443", + interceptors=[mock_async_interceptor], + ) + + +def test_create_async_channel_with_otel_omitted_interceptors(monkeypatch): + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + mock_async_interceptor = mock.Mock(name="otel_async_interceptor") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor + + 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.create_async_channel_with_otel( + mock_channel_factory, + client_options=options, + target="example.com:443", + ) + + assert result is mock_async_channel + mock_otel_grpc.aio_client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + mock_channel_factory.assert_called_once_with( + target="example.com:443", + interceptors=[mock_async_interceptor], + ) From d1f4ccd0f5e37195efd367adf92d5b3dd85920aa Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 28 Aug 2026 09:15:47 -0400 Subject: [PATCH 09/13] feat(api-core): support positional *channel_args and partial application in channel factories - Add *channel_args to create_channel_with_otel and create_async_channel_with_otel - Make client_options keyword-only to prevent argument collision with functools.partial - Add TDD unit tests with detailed docstrings for positional forwarding and partial binding --- .../google/api_core/_observability.py | 12 +- .../tests/unit/test_observability.py | 152 ++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index d8881d15c601..32193b5b2eca 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -83,6 +83,7 @@ def _get_otel_interceptor( def create_channel_with_otel( channel_factory: Callable[..., Any], + *channel_args: Any, client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None, **channel_kwargs: Any, ) -> Any: @@ -97,12 +98,15 @@ def create_channel_with_otel( that instantiates and returns a raw gRPC channel. client_options: The client options object or dictionary used for feature gating and extracting the tracer provider. + *channel_args: Positional arguments forwarded directly to `channel_factory` (e.g. `host`). + Supporting positional arguments allows this function to be easily bound via + `functools.partial` in Client initialization. **channel_kwargs: Keyword arguments forwarded directly to `channel_factory`. Returns: Any: The intercepted or raw gRPC channel. """ - raw_channel = channel_factory(**channel_kwargs) + raw_channel = channel_factory(*channel_args, **channel_kwargs) if is_otel_capabilities_enabled(client_options): import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] @@ -113,6 +117,7 @@ def create_channel_with_otel( def create_async_channel_with_otel( channel_factory: Callable[..., Any], + *channel_args: Any, client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None, **channel_kwargs: Any, ) -> Any: @@ -126,6 +131,9 @@ def create_async_channel_with_otel( that instantiates and returns an async gRPC channel. client_options: The client options object or dictionary used for feature gating and extracting the tracer provider. + *channel_args: Positional arguments forwarded directly to `channel_factory` (e.g. `host`). + Supporting positional arguments allows this function to be easily bound via + `functools.partial` in Client initialization. **channel_kwargs: Keyword arguments forwarded directly to `channel_factory`. Returns: @@ -137,4 +145,4 @@ def create_async_channel_with_otel( interceptors.append(async_interceptor) channel_kwargs["interceptors"] = interceptors - return channel_factory(**channel_kwargs) + return channel_factory(*channel_args, **channel_kwargs) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index edae8c70699d..1c1dc937a0b0 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import functools import sys from unittest import mock @@ -366,3 +367,154 @@ def test_create_async_channel_with_otel_omitted_interceptors(monkeypatch): target="example.com:443", interceptors=[mock_async_interceptor], ) + + +def test_create_channel_with_otel_positional_args(monkeypatch): + """Proves that create_channel_with_otel forwards positional arguments (*channel_args) + to the underlying channel_factory callable. + + Why this matters: Transports pass host as a positional argument + (e.g., channel_init(self._host, credentials=...)), so the helper must pass + positional arguments through without argument-binding errors. + """ + mock_raw_channel = mock.Mock(name="raw_channel") + mock_channel_factory = mock.Mock(return_value=mock_raw_channel) + + result = _observability.create_channel_with_otel( + mock_channel_factory, + "example.com:443", # positional host argument + credentials="mock_creds", + ) + + assert result is mock_raw_channel + mock_channel_factory.assert_called_once_with( + "example.com:443", credentials="mock_creds" + ) + + +def test_create_channel_with_otel_partial_application(monkeypatch): + """Proves that create_channel_with_otel can be bound with functools.partial + (e.g. functools.partial(create_channel_with_otel, channel_factory, client_options=options)) + and subsequently called by a Transport with positional (*channel_args) and keyword (**channel_kwargs) args. + + Why this matters: This allows Client.__init__ to pass a lazy factory to + Transport(channel=partial(...)) without needing to eagerly extract and duplicate + credentials, scopes, and quota_project_id. + """ + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_raw_channel = mock.Mock(name="raw_channel") + mock_wrapped_channel = mock.Mock(name="wrapped_channel") + mock_channel_factory = mock.Mock(return_value=mock_raw_channel) + + 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_wrapped_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 + ) + + # 1. Client creates lazy factory using functools.partial + lazy_factory = functools.partial( + _observability.create_channel_with_otel, + mock_channel_factory, + client_options=options, + ) + + # 2. Transport invokes the factory passing host positionally and credentials by keyword + result = lazy_factory( + "secretmanager.googleapis.com:443", + credentials="mock_credentials", + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + + assert result is mock_wrapped_channel + mock_channel_factory.assert_called_once_with( + "secretmanager.googleapis.com:443", + credentials="mock_credentials", + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_raw_channel, mock_interceptor + ) + + +def test_create_async_channel_with_otel_positional_args(monkeypatch): + """Proves that create_async_channel_with_otel forwards positional arguments (*channel_args) + to the underlying async channel_factory callable. + """ + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + + result = _observability.create_async_channel_with_otel( + mock_channel_factory, + "example.com:443", # positional host argument + credentials="mock_creds", + ) + + assert result is mock_async_channel + mock_channel_factory.assert_called_once_with( + "example.com:443", credentials="mock_creds" + ) + + +def test_create_async_channel_with_otel_partial_application(monkeypatch): + """Proves that create_async_channel_with_otel can be bound with functools.partial + and called by an Async Transport with positional host and keyword arguments, + injecting the async OTel interceptor seamlessly into kwargs['interceptors']. + """ + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_async_channel = mock.Mock(name="async_channel") + mock_channel_factory = mock.Mock(return_value=mock_async_channel) + user_interceptor = mock.Mock(name="user_interceptor") + mock_async_interceptor = mock.Mock(name="otel_async_interceptor") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor + + 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 + ) + + # 1. Client creates lazy factory using functools.partial + lazy_factory = functools.partial( + _observability.create_async_channel_with_otel, + mock_channel_factory, + client_options=options, + ) + + # 2. Transport invokes the factory passing host positionally and interceptors by keyword + result = lazy_factory( + "secretmanager.googleapis.com:443", + credentials="mock_credentials", + interceptors=[user_interceptor], + ) + + assert result is mock_async_channel + mock_otel_grpc.aio_client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) + mock_channel_factory.assert_called_once_with( + "secretmanager.googleapis.com:443", + credentials="mock_credentials", + interceptors=[user_interceptor, mock_async_interceptor], + ) From cf9552348e253738da5fb579437cbcc256be771c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 28 Aug 2026 09:56:13 -0400 Subject: [PATCH 10/13] test(api-core): update eager channel tests to set experimental env var --- packages/google-api-core/tests/unit/test_observability.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 1c1dc937a0b0..7b4b95341b63 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -297,6 +297,7 @@ def test_create_async_channel_with_otel_enabled(monkeypatch): def test_create_async_channel_with_otel_none_interceptors(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() options = ClientOptions(tracer_provider=mock_tracer_provider) @@ -334,6 +335,7 @@ def test_create_async_channel_with_otel_none_interceptors(monkeypatch): def test_create_async_channel_with_otel_omitted_interceptors(monkeypatch): + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() options = ClientOptions(tracer_provider=mock_tracer_provider) @@ -401,6 +403,7 @@ def test_create_channel_with_otel_partial_application(monkeypatch): Transport(channel=partial(...)) without needing to eagerly extract and duplicate credentials, scopes, and quota_project_id. """ + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() options = ClientOptions(tracer_provider=mock_tracer_provider) @@ -475,6 +478,7 @@ def test_create_async_channel_with_otel_partial_application(monkeypatch): and called by an Async Transport with positional host and keyword arguments, injecting the async OTel interceptor seamlessly into kwargs['interceptors']. """ + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") mock_tracer_provider = object() options = ClientOptions(tracer_provider=mock_tracer_provider) From 696200e17ec2a610ea8f8dac8d43a06a507345a3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 13:17:02 -0400 Subject: [PATCH 11/13] feat(secretmanager): integrate eager channel creation and interceptor application - Use _observability.create_channel_with_otel in SecretManagerServiceClient - Use grpc_helpers.apply_interceptors in SecretManagerServiceGrpcTransport - Add unit tests for channel injection and interceptor wiring --- .../services/secret_manager_service/client.py | 44 ++++++---- .../secret_manager_service/transports/grpc.py | 12 ++- .../google-cloud-secret-manager/noxfile.py | 4 +- packages/google-cloud-secret-manager/setup.py | 2 +- .../testing/constraints-3.10.txt | 2 +- .../test_secret_manager_service.py | 80 ++++++++++++++++++- 6 files changed, 122 insertions(+), 22 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..d909680cafc5 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 _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,33 @@ 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, + } + + if transport_init is SecretManagerServiceGrpcTransport: + if _observability.is_otel_capabilities_enabled(self._client_options): + transport_kwargs["channel"] = ( + _observability.create_channel_with_otel( + SecretManagerServiceGrpcTransport.create_channel, + client_options=self._client_options, + host=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, + ) + ) + + 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..6dcebc3da558 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 @@ -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 @@ -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. @@ -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. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -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 diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..edc95c3289fb 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -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]] = {} diff --git a/packages/google-cloud-secret-manager/setup.py b/packages/google-cloud-secret-manager/setup.py index 69abc90c64cb..551996a45c94 100644 --- a/packages/google-cloud-secret-manager/setup.py +++ b/packages/google-cloud-secret-manager/setup.py @@ -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", diff --git a/packages/google-cloud-secret-manager/testing/constraints-3.10.txt b/packages/google-cloud-secret-manager/testing/constraints-3.10.txt index 0ce4b3d6e6f5..9e261ce48fe5 100644 --- a/packages/google-cloud-secret-manager/testing/constraints-3.10.txt +++ b/packages/google-cloud-secret-manager/testing/constraints-3.10.txt @@ -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 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..62b578d879e0 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,83 @@ def test_secret_manager_service_client_client_options( ) +def test_secret_manager_service_client_otel_channel_injection_enabled(): + mock_wrapped_channel = mock.Mock() + + 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( + "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.create_channel_with_otel", + return_value=mock_wrapped_channel, + ) as mock_create_channel_with_otel, + mock.patch.object( + transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SecretManagerServiceClient(transport="grpc") + + mock_is_enabled.assert_called_once() + mock_create_channel_with_otel.assert_called_once_with( + transports.SecretManagerServiceGrpcTransport.create_channel, + client_options=client._client_options, + host=client._api_endpoint, + credentials=None, + credentials_file=None, + scopes=None, + quota_project_id=None, + ) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("channel") is mock_wrapped_channel + + +def test_secret_manager_service_client_otel_channel_injection_disabled(): + 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(): + 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", [ From f2b6829e1ece71740bec74f5c4a5b14d78e88d24 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 27 Aug 2026 13:56:53 -0400 Subject: [PATCH 12/13] docs(secretmanager): add descriptive docstrings to OTel and transport unit tests --- .../test_secret_manager_service.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 62b578d879e0..3e3e57851a96 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 @@ -770,6 +770,15 @@ 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 eagerly invokes _observability.create_channel_with_otel with + SecretManagerServiceGrpcTransport.create_channel and client configuration. + 3. The eagerly created and wrapped OTel channel is injected into the transport's + constructor kwargs under the 'channel' key. + """ mock_wrapped_channel = mock.Mock() with ( @@ -802,6 +811,13 @@ def test_secret_manager_service_client_otel_channel_injection_enabled(): 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", @@ -823,6 +839,10 @@ def test_secret_manager_service_client_otel_channel_injection_disabled(): 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() From 2669ca4f5a9c700be144e76944da7d8c35ddf02f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 28 Aug 2026 09:19:06 -0400 Subject: [PATCH 13/13] feat(secretmanager): pass lazy partial channel factory when OTel tracing enabled - Use functools.partial to bind create_channel_with_otel with Transport.create_channel and client_options - Eliminate manual extraction of host, credentials, scopes, and quota_project_id in client - Update unit tests to verify functools.partial factory binding and lazy transport kwargs --- .../services/secret_manager_service/client.py | 19 +++++------ .../test_secret_manager_service.py | 34 ++++++++----------- 2 files changed, 24 insertions(+), 29 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 d909680cafc5..4a6f1775f772 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 @@ -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 @@ -756,18 +757,16 @@ def __init__( "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"] = ( - _observability.create_channel_with_otel( - SecretManagerServiceGrpcTransport.create_channel, - client_options=self._client_options, - host=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, - ) + transport_kwargs["channel"] = functools.partial( + _observability.create_channel_with_otel, + SecretManagerServiceGrpcTransport.create_channel, + client_options=self._client_options, ) self._transport = transport_init(**transport_kwargs) 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 3e3e57851a96..37d10d0abc38 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 @@ -774,22 +775,16 @@ def test_secret_manager_service_client_otel_channel_injection_enabled(): 1. SecretManagerServiceClient detects the feature flag via _observability.is_otel_capabilities_enabled. - 2. The client eagerly invokes _observability.create_channel_with_otel with - SecretManagerServiceGrpcTransport.create_channel and client configuration. - 3. The eagerly created and wrapped OTel channel is injected into the transport's - constructor kwargs under the 'channel' key. + 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. """ - mock_wrapped_channel = mock.Mock() - 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( - "google.cloud.secretmanager_v1.services.secret_manager_service.client._observability.create_channel_with_otel", - return_value=mock_wrapped_channel, - ) as mock_create_channel_with_otel, mock.patch.object( transports.SecretManagerServiceGrpcTransport, "__init__", return_value=None ) as patched_transport_init, @@ -797,17 +792,18 @@ def test_secret_manager_service_client_otel_channel_injection_enabled(): client = SecretManagerServiceClient(transport="grpc") mock_is_enabled.assert_called_once() - mock_create_channel_with_otel.assert_called_once_with( + 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, - client_options=client._client_options, - host=client._api_endpoint, - credentials=None, - credentials_file=None, - scopes=None, - quota_project_id=None, ) - called_kwargs = patched_transport_init.call_args.kwargs - assert called_kwargs.get("channel") is mock_wrapped_channel + assert channel_factory.keywords == {"client_options": client._client_options} def test_secret_manager_service_client_otel_channel_injection_disabled():