Skip to content

Commit 3687533

Browse files
authored
feat(api-core): add channel orchestration for OpenTelemetry (B) (#18237)
This pull request introduces OpenTelemetry helper functions in `google.api_core._observability` to produce channel wrappers for synchronous gRPC channels and interceptors for asynchronous gRPC channels. ### Problem Generated client libraries need a consistent and maintainable way to instrument gRPC channels with OpenTelemetry tracing when enabled via environment variables or client options. Because synchronous gRPC channels can be wrapped post-creation while asynchronous gRPC channels require interceptors at channel creation time, client transports need helpers that return the appropriate channel wrapper or async interceptors without duplicating OpenTelemetry resolution logic across client libraries. ### Solution This pull request introduces the following helper functions in `google.api_core._observability`: 1. `get_otel_channel_wrapper(client_options)`: * Returns a channel-wrapping function (`Callable[[Channel], Channel]`) for synchronous gRPC channels when OpenTelemetry tracing is enabled and installed. * Integrates with `grpc_helpers.apply_channel_wrappers` to wrap raw channels using OpenTelemetry's `intercept_channel`. 2. `get_otel_async_interceptor(client_options)`: * Returns a list of OpenTelemetry asynchronous client interceptors (`aio_client_interceptors`) for use when constructing `grpc.aio` channels. 3. `_get_otel_interceptor(client_options, is_async)`: * Internal helper that extracts `tracer_provider` from `ClientOptions` and creates the appropriate OpenTelemetry sync or async interceptors. ### Testing * Added unit tests in `tests/unit/test_observability.py` covering: * Sync and async interceptor extraction and `tracer_provider` configuration. * `get_otel_channel_wrapper` behavior when tracing is disabled, when OpenTelemetry is not installed, and when tracing is enabled. * Integration between `get_otel_channel_wrapper` and `grpc_helpers.apply_channel_wrappers`. * `get_otel_async_interceptor` behavior across disabled, missing, and enabled states. ### Notes for Reviewers * This PR builds upon PR #18236 (`ChannelWrapper` and `apply_channel_wrappers`). * `get_otel_channel_wrapper` returns a callable rather than modifying the channel immediately, allowing transport layers to combine OpenTelemetry wrapping with user-supplied custom channel wrappers.
1 parent f89cfbd commit 3687533

2 files changed

Lines changed: 210 additions & 53 deletions

File tree

packages/google-api-core/google/api_core/_observability.py

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,21 @@
1616

1717
"""OpenTelemetry helpers for resolving and instantiating interceptors."""
1818

19-
from typing import Any, Optional
19+
from typing import TYPE_CHECKING, Any, Callable
2020

2121
from google.api_core import _feature_gating_helpers
2222
from google.api_core.client_options import ClientOptions
2323

24+
if TYPE_CHECKING:
25+
from google.api_core.grpc_helpers import ChannelWrapperCallable
26+
else:
27+
ChannelWrapperCallable = Callable[[Any], Any]
28+
2429
_TRACER_PROVIDER = "tracer_provider"
2530

2631

2732
def is_otel_capabilities_enabled(
28-
client_options: Optional[ClientOptions | dict[str, Any]] = None,
33+
client_options: ClientOptions | dict[str, Any] | None = None,
2934
env_var: str = "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
3035
) -> bool:
3136
"""Checks if OTel capabilities are enabled and installed.
@@ -54,26 +59,19 @@ def is_otel_capabilities_enabled(
5459
return False
5560

5661

57-
def apply_otel_capabilities_to_channel(
58-
channel: Any,
59-
client_options: Optional[ClientOptions | dict[str, Any]] = None,
62+
def _get_otel_interceptor(
63+
client_options: ClientOptions | dict[str, Any] | None = None,
64+
is_async: bool = False,
6065
) -> Any:
61-
"""Applies OTel capabilities (like tracing) to the channel.
62-
63-
Precondition: This function assumes `is_otel_capabilities_enabled` has already
64-
been called and returned `True`, i.e. in the Client. At this time
65-
this function is not intended to be standalone.
66+
"""Instantiates a sync or async OpenTelemetry gRPC client interceptor.
6667
6768
Args:
68-
channel: The raw gRPC channel to wrap.
6969
client_options: The client options object or dictionary.
70+
is_async: If True, returns an async interceptor (`aio_client_interceptor`),
71+
otherwise returns a sync interceptor (`client_interceptor`).
7072
7173
Returns:
72-
Any: The intercepted channel.
73-
74-
Raises:
75-
ImportError: If OpenTelemetry packages are not installed and this function
76-
is called directly (bypassing the precondition).
74+
Any: The instantiated OpenTelemetry client interceptor.
7775
"""
7876
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
7977

@@ -83,7 +81,51 @@ def apply_otel_capabilities_to_channel(
8381
elif client_options is not None:
8482
tracer_provider = getattr(client_options, _TRACER_PROVIDER, None)
8583

86-
interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider)
84+
if is_async:
85+
return otel_grpc.aio_client_interceptors(tracer_provider=tracer_provider)
86+
return otel_grpc.client_interceptor(tracer_provider=tracer_provider)
87+
88+
89+
def get_otel_channel_wrapper(
90+
client_options: ClientOptions | dict[str, Any] | None = None,
91+
) -> ChannelWrapperCallable | None:
92+
"""Returns a channel wrapper callable that wraps a sync gRPC channel with OpenTelemetry tracing.
93+
94+
Args:
95+
client_options: The client options object or dictionary used for feature gating
96+
and extracting the tracer provider.
97+
98+
Returns:
99+
Optional[ChannelWrapperCallable]: A channel-wrapping callable if OpenTelemetry
100+
tracing is enabled and installed, None otherwise.
101+
"""
102+
if not is_otel_capabilities_enabled(client_options):
103+
return None
104+
105+
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
106+
107+
interceptor = _get_otel_interceptor(client_options, is_async=False)
108+
109+
def channel_wrapper(channel: Any) -> Any:
110+
return otel_grpc.intercept_channel(channel, interceptor)
111+
112+
return channel_wrapper
113+
114+
115+
def get_otel_async_interceptor(
116+
client_options: ClientOptions | dict[str, Any] | None = None,
117+
) -> Any | None:
118+
"""Returns an async gRPC client interceptor for OpenTelemetry tracing.
119+
120+
Args:
121+
client_options: The client options object or dictionary used for feature gating
122+
and extracting the tracer provider.
123+
124+
Returns:
125+
Optional[Any]: An instantiated OpenTelemetry async client interceptor
126+
if tracing is enabled and installed, None otherwise.
127+
"""
128+
if not is_otel_capabilities_enabled(client_options):
129+
return None
87130

88-
# We use OTel's own compatible applier to avoid standard gRPC TypeError.
89-
return otel_grpc.intercept_channel(channel, interceptor)
131+
return _get_otel_interceptor(client_options, is_async=True)

packages/google-api-core/tests/unit/test_observability.py

Lines changed: 149 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,57 @@ def test_is_otel_capabilities_enabled_experimental_enabled_with_config(monkeypat
8787
assert _observability.is_otel_capabilities_enabled(options)
8888

8989

90-
def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch):
91-
mock_channel = mock.Mock()
92-
mock_intercepted_channel = mock.Mock()
90+
def test_get_otel_interceptor_sync_default(monkeypatch):
91+
mock_otel = mock.Mock()
92+
mock_otel_grpc = mock_otel.instrumentation.grpc
93+
mock_interceptor = mock.Mock()
94+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
95+
96+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
97+
monkeypatch.setitem(
98+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
99+
)
100+
monkeypatch.setitem(
101+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
102+
)
103+
104+
result = _observability._get_otel_interceptor()
105+
assert result is mock_interceptor
106+
mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None)
107+
108+
109+
def test_get_otel_interceptor_sync_config(monkeypatch):
110+
mock_tracer_provider = object()
111+
options = ClientOptions(tracer_provider=mock_tracer_provider)
93112

94113
mock_otel = mock.Mock()
95114
mock_otel_grpc = mock_otel.instrumentation.grpc
96115
mock_interceptor = mock.Mock()
116+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
117+
118+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
119+
monkeypatch.setitem(
120+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
121+
)
122+
monkeypatch.setitem(
123+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
124+
)
97125

126+
result = _observability._get_otel_interceptor(client_options=options)
127+
assert result is mock_interceptor
128+
mock_otel_grpc.client_interceptor.assert_called_once_with(
129+
tracer_provider=mock_tracer_provider
130+
)
131+
132+
133+
def test_get_otel_interceptor_sync_dict_config(monkeypatch):
134+
mock_tracer_provider = object()
135+
options = {"tracer_provider": mock_tracer_provider}
136+
137+
mock_otel = mock.Mock()
138+
mock_otel_grpc = mock_otel.instrumentation.grpc
139+
mock_interceptor = mock.Mock()
98140
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
99-
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
100141

101142
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
102143
monkeypatch.setitem(
@@ -106,29 +147,62 @@ def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch):
106147
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
107148
)
108149

109-
result = _observability.apply_otel_capabilities_to_channel(mock_channel)
150+
result = _observability._get_otel_interceptor(client_options=options)
151+
assert result is mock_interceptor
152+
mock_otel_grpc.client_interceptor.assert_called_once_with(
153+
tracer_provider=mock_tracer_provider
154+
)
155+
110156

111-
assert result is mock_intercepted_channel
112-
mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None)
113-
mock_otel_grpc.intercept_channel.assert_called_once_with(
114-
mock_channel, mock_interceptor
157+
def test_get_otel_interceptor_async(monkeypatch):
158+
mock_tracer_provider = object()
159+
options = ClientOptions(tracer_provider=mock_tracer_provider)
160+
161+
mock_otel = mock.Mock()
162+
mock_otel_grpc = mock_otel.instrumentation.grpc
163+
mock_async_interceptors = [mock.Mock()]
164+
mock_otel_grpc.aio_client_interceptors.return_value = mock_async_interceptors
165+
166+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
167+
monkeypatch.setitem(
168+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
115169
)
170+
monkeypatch.setitem(
171+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
172+
)
173+
174+
result = _observability._get_otel_interceptor(client_options=options, is_async=True)
175+
assert result is mock_async_interceptors
176+
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
177+
tracer_provider=mock_tracer_provider
178+
)
179+
180+
181+
def test_get_otel_channel_wrapper_disabled(monkeypatch):
182+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false")
183+
assert _observability.get_otel_channel_wrapper() is None
184+
185+
186+
def test_get_otel_channel_wrapper_otel_missing(monkeypatch):
187+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
188+
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None)
189+
assert _observability.get_otel_channel_wrapper() is None
116190

117191

118-
def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch):
119-
# Tracing enabled via config (tracer_provider is set)
192+
def test_get_otel_channel_wrapper_enabled(monkeypatch):
193+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
120194
mock_tracer_provider = object()
121195
options = ClientOptions(tracer_provider=mock_tracer_provider)
122196

123-
mock_channel = mock.Mock()
124-
mock_intercepted_channel = mock.Mock()
197+
mock_raw_channel = mock.Mock(name="raw_channel")
198+
mock_wrapped_channel = mock.Mock(name="wrapped_channel")
125199

126200
mock_otel = mock.Mock()
127201
mock_otel_grpc = mock_otel.instrumentation.grpc
128-
mock_interceptor = mock.Mock()
202+
mock_interceptor = mock.Mock(name="otel_interceptor")
129203

130204
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
131-
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
205+
mock_otel_grpc.intercept_channel.return_value = mock_wrapped_channel
132206

133207
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
134208
monkeypatch.setitem(
@@ -138,33 +212,38 @@ def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch):
138212
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
139213
)
140214

141-
result = _observability.apply_otel_capabilities_to_channel(
142-
mock_channel, client_options=options
143-
)
215+
wrapper = _observability.get_otel_channel_wrapper(client_options=options)
216+
assert callable(wrapper)
144217

145-
assert result is mock_intercepted_channel
146218
mock_otel_grpc.client_interceptor.assert_called_once_with(
147219
tracer_provider=mock_tracer_provider
148220
)
221+
222+
result = wrapper(mock_raw_channel)
223+
assert result is mock_wrapped_channel
149224
mock_otel_grpc.intercept_channel.assert_called_once_with(
150-
mock_channel, mock_interceptor
225+
mock_raw_channel, mock_interceptor
151226
)
152227

153228

154-
def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch):
155-
# Tracing enabled via dict config
229+
def test_get_otel_channel_wrapper_with_apply_channel_wrappers(monkeypatch):
230+
"""Proves that get_otel_channel_wrapper integrates seamlessly into apply_channel_wrappers."""
231+
pytest.importorskip("grpc")
232+
from google.api_core import grpc_helpers
233+
234+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
156235
mock_tracer_provider = object()
157-
options = {"tracer_provider": mock_tracer_provider}
236+
options = ClientOptions(tracer_provider=mock_tracer_provider)
158237

159-
mock_channel = mock.Mock()
160-
mock_intercepted_channel = mock.Mock()
238+
mock_raw_channel = mock.Mock(name="raw_channel")
239+
mock_wrapped_channel = mock.Mock(name="wrapped_channel")
161240

162241
mock_otel = mock.Mock()
163242
mock_otel_grpc = mock_otel.instrumentation.grpc
164-
mock_interceptor = mock.Mock()
243+
mock_interceptor = mock.Mock(name="otel_interceptor")
165244

166245
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
167-
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
246+
mock_otel_grpc.intercept_channel.return_value = mock_wrapped_channel
168247

169248
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
170249
monkeypatch.setitem(
@@ -174,14 +253,50 @@ def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch)
174253
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
175254
)
176255

177-
result = _observability.apply_otel_capabilities_to_channel(
178-
mock_channel, client_options=options
179-
)
256+
otel_wrapper = _observability.get_otel_channel_wrapper(client_options=options)
257+
assert callable(otel_wrapper)
180258

181-
assert result is mock_intercepted_channel
182-
mock_otel_grpc.client_interceptor.assert_called_once_with(
183-
tracer_provider=mock_tracer_provider
259+
result = grpc_helpers.apply_channel_wrappers(
260+
mock_raw_channel, wrappers=[otel_wrapper]
184261
)
262+
assert result is mock_wrapped_channel
185263
mock_otel_grpc.intercept_channel.assert_called_once_with(
186-
mock_channel, mock_interceptor
264+
mock_raw_channel, mock_interceptor
265+
)
266+
267+
268+
def test_get_otel_async_interceptor_disabled(monkeypatch):
269+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false")
270+
assert _observability.get_otel_async_interceptor() is None
271+
272+
273+
def test_get_otel_async_interceptor_otel_missing(monkeypatch):
274+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
275+
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None)
276+
assert _observability.get_otel_async_interceptor() is None
277+
278+
279+
def test_get_otel_async_interceptor_enabled(monkeypatch):
280+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
281+
mock_tracer_provider = object()
282+
options = ClientOptions(tracer_provider=mock_tracer_provider)
283+
284+
mock_async_interceptors = [mock.Mock(name="otel_async_interceptor")]
285+
286+
mock_otel = mock.Mock()
287+
mock_otel_grpc = mock_otel.instrumentation.grpc
288+
mock_otel_grpc.aio_client_interceptors.return_value = mock_async_interceptors
289+
290+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
291+
monkeypatch.setitem(
292+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
293+
)
294+
monkeypatch.setitem(
295+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
296+
)
297+
298+
result = _observability.get_otel_async_interceptor(client_options=options)
299+
assert result is mock_async_interceptors
300+
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
301+
tracer_provider=mock_tracer_provider
187302
)

0 commit comments

Comments
 (0)