Skip to content

Commit 9ffebc2

Browse files
authored
feat(api-core): add tracer_provider to ClientOptions for OTel support (#18139)
## Problem Generated client libraries require a mechanism to enable OpenTelemetry (OTel) capabilities (such as tracing) without introducing hard dependencies on the OTel SDK in the core runtime or cluttering generated code with complex feature flag resolution and fallback logic. ## Solution This PR introduces foundational helpers in `google-api-core` to centralize the resolution and application of OTel capabilities. 1. **Client Options Update:** Added `tracer_provider` to `ClientOptions` to allow programmatic configuration of the tracer provider. 2. **Capability Helpers:** Added `google.api_core._otel_helpers` containing: - `is_otel_capabilities_enabled()`: Checks environment variables and configuration to determine if OTel is requested and available. - `apply_otel_capabilities_to_channel()`: Wraps a gRPC channel with OTel interception using OTel's specialized `intercept_channel` to avoid compatibility issues with standard `grpc.intercept_channel`. ## Notes to Reviewers - These helpers fail open silently if OTel is requested but the required packages are not installed. - This approach abstracts the OTel-specific logic (including `grpcext` compatibility) away from generated transports, keeping them standard and maintainable. - This is a foundational PR (Phase 1). Downstream usage in generated libraries will follow in subsequent PRs.
1 parent 3e0bda4 commit 9ffebc2

8 files changed

Lines changed: 268 additions & 1 deletion

File tree

mypy.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ ignore_missing_imports = True
8282
ignore_missing_imports = True
8383

8484

85+
# OpenTelemetry is an optional dependency and may not be installed in all test
86+
# environments (e.g. to verify core functionality works without it).
87+
[mypy-opentelemetry.*]
88+
ignore_missing_imports = True
89+
8590
# ==============================================================================
8691
# PACKAGE-SPECIFIC OVERRIDES & EXCEPTIONS
8792
# ==============================================================================
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""OpenTelemetry helpers for resolving and instantiating interceptors."""
18+
19+
from typing import Any, Optional
20+
21+
from google.api_core import _feature_gating_helpers
22+
from google.api_core.client_options import ClientOptions
23+
24+
_TRACER_PROVIDER = "tracer_provider"
25+
26+
27+
def is_otel_capabilities_enabled(
28+
client_options: Optional[ClientOptions | dict[str, Any]] = None,
29+
env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
30+
) -> bool:
31+
"""Checks if OTel capabilities are enabled and installed.
32+
33+
Args:
34+
client_options: The client options object or dictionary.
35+
env_var: The environment variable to check for enablement.
36+
37+
Returns:
38+
bool: True if enabled and installed, False otherwise.
39+
"""
40+
is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags(
41+
env_var=env_var,
42+
feature_key=_TRACER_PROVIDER,
43+
configuration=client_options,
44+
)
45+
46+
if is_tracing_enabled:
47+
try:
48+
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] # noqa: F401
49+
50+
return True
51+
except ImportError:
52+
pass
53+
54+
return False
55+
56+
57+
def apply_otel_capabilities_to_channel(
58+
channel: Any,
59+
client_options: Optional[ClientOptions | dict[str, Any]] = None,
60+
) -> 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+
67+
Args:
68+
channel: The raw gRPC channel to wrap.
69+
client_options: The client options object or dictionary.
70+
71+
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).
77+
"""
78+
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
79+
80+
tracer_provider = None
81+
if isinstance(client_options, dict):
82+
tracer_provider = client_options.get(_TRACER_PROVIDER)
83+
elif client_options is not None:
84+
tracer_provider = getattr(client_options, _TRACER_PROVIDER, None)
85+
86+
interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider)
87+
88+
# We use OTel's own compatible applier to avoid standard gRPC TypeError.
89+
return otel_grpc.intercept_channel(channel, interceptor)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ def get_client_cert():
4848
4949
"""
5050

51+
import typing
5152
import warnings
5253
from typing import Callable, Mapping, Optional, Sequence, Tuple
5354

55+
if typing.TYPE_CHECKING:
56+
import opentelemetry.trace
57+
5458
from google.api_core import general_helpers
5559

5660

@@ -98,6 +102,8 @@ class ClientOptions(object):
98102
`googleapis.com`. If both `api_endpoint` and `universe_domain` are set,
99103
then `api_endpoint` is used as the service endpoint. If `api_endpoint` is
100104
not specified, the format will be `{service}.{universe_domain}`.
105+
tracer_provider (Optional["opentelemetry.trace.TracerProvider"]): The OpenTelemetry tracer provider to use
106+
for tracing in supported libraries.
101107
102108
Raises:
103109
ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source``
@@ -117,6 +123,7 @@ def __init__(
117123
api_key: Optional[str] = None,
118124
api_audience: Optional[str] = None,
119125
universe_domain: Optional[str] = None,
126+
tracer_provider: Optional["opentelemetry.trace.TracerProvider"] = None,
120127
):
121128
if credentials_file is not None:
122129
warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning)
@@ -136,6 +143,7 @@ def __init__(
136143
self.api_key = api_key
137144
self.api_audience = api_audience
138145
self.universe_domain = universe_domain
146+
self.tracer_provider = tracer_provider
139147

140148
def __repr__(self) -> str:
141149
return "ClientOptions: " + repr(self.__dict__)

packages/google-api-core/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies = [
4848
"proto-plus >= 1.26.1, < 2.0.0",
4949
"google-auth >= 2.14.1, < 3.0.0",
5050
"requests >= 2.33.0, < 3.0.0",
51+
"opentelemetry-api >= 1.44.0, < 2.0.0",
5152
]
5253
dynamic = ["version"]
5354

@@ -64,6 +65,12 @@ grpc = [
6465
"grpcio-status >= 1.59.0, < 2.0.0",
6566
"grpcio-status >= 1.75.1, < 2.0.0; python_version >= '3.14'",
6667
]
68+
tracing = [
69+
"opentelemetry-instrumentation-grpc >= 0.65b0, < 1.0.0",
70+
]
71+
testing = [
72+
"opentelemetry-sdk >= 1.44.0, < 2.0.0",
73+
]
6774

6875

6976
[tool.setuptools.dynamic]

packages/google-api-core/testing/constraints-3.10.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ requests==2.33.0
1212
grpcio==1.59.0
1313
grpcio-status==1.59.0
1414
proto-plus==1.26.1
15+
opentelemetry-api==1.44.0
16+
opentelemetry-instrumentation-grpc==0.65b0
17+
opentelemetry-sdk==1.44.0

packages/google-api-core/testing/constraints-async-rest-3.10.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ grpcio==1.59.0
1313
grpcio-status==1.59.0
1414
proto-plus==1.26.1
1515
aiohttp==3.13.4
16+
opentelemetry-api==1.44.0
17+
opentelemetry-instrumentation-grpc==0.65b0
18+
opentelemetry-sdk==1.44.0

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from re import match
1616

1717
import pytest
18-
1918
from google.api_core import client_options
2019

2120
from ..helpers import warn_deprecated_credentials_file
@@ -30,6 +29,7 @@ def get_client_encrypted_cert():
3029

3130

3231
def test_constructor():
32+
mock_tracer_provider = object()
3333
with warn_deprecated_credentials_file():
3434
options = client_options.ClientOptions(
3535
api_endpoint="foo.googleapis.com",
@@ -42,6 +42,7 @@ def test_constructor():
4242
],
4343
api_audience="foo2.googleapis.com",
4444
universe_domain="googleapis.com",
45+
tracer_provider=mock_tracer_provider,
4546
)
4647

4748
assert options.api_endpoint == "foo.googleapis.com"
@@ -54,6 +55,7 @@ def test_constructor():
5455
]
5556
assert options.api_audience == "foo2.googleapis.com"
5657
assert options.universe_domain == "googleapis.com"
58+
assert options.tracer_provider is mock_tracer_provider
5759

5860

5961
def test_constructor_with_encrypted_cert_source():
@@ -162,6 +164,7 @@ def test_repr():
162164
"scopes",
163165
"api_key",
164166
"api_audience",
167+
"tracer_provider",
165168
]
166169
)
167170
options = client_options.ClientOptions(api_endpoint="foo.googleapis.com")
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import sys
16+
from unittest import mock
17+
18+
from google.api_core import _observability
19+
from google.api_core.client_options import ClientOptions
20+
21+
22+
def test_is_otel_capabilities_enabled_disabled(monkeypatch):
23+
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false")
24+
assert not _observability.is_otel_capabilities_enabled()
25+
26+
27+
def test_is_otel_capabilities_enabled_otel_missing(monkeypatch):
28+
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")
29+
# Simulate OTel not being installed by blocking imports
30+
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None)
31+
32+
assert not _observability.is_otel_capabilities_enabled()
33+
34+
35+
def test_is_otel_capabilities_enabled_otel_installed(monkeypatch):
36+
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")
37+
38+
mock_otel = mock.Mock()
39+
mock_otel_grpc = mock_otel.instrumentation.grpc
40+
41+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
42+
monkeypatch.setitem(
43+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
44+
)
45+
monkeypatch.setitem(
46+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
47+
)
48+
49+
assert _observability.is_otel_capabilities_enabled()
50+
51+
52+
def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch):
53+
mock_channel = mock.Mock()
54+
mock_intercepted_channel = mock.Mock()
55+
56+
mock_otel = mock.Mock()
57+
mock_otel_grpc = mock_otel.instrumentation.grpc
58+
mock_interceptor = mock.Mock()
59+
60+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
61+
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
62+
63+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
64+
monkeypatch.setitem(
65+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
66+
)
67+
monkeypatch.setitem(
68+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
69+
)
70+
71+
result = _observability.apply_otel_capabilities_to_channel(mock_channel)
72+
73+
assert result is mock_intercepted_channel
74+
mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None)
75+
mock_otel_grpc.intercept_channel.assert_called_once_with(
76+
mock_channel, mock_interceptor
77+
)
78+
79+
80+
def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch):
81+
# Tracing enabled via config (tracer_provider is set)
82+
mock_tracer_provider = object()
83+
options = ClientOptions(tracer_provider=mock_tracer_provider)
84+
85+
mock_channel = mock.Mock()
86+
mock_intercepted_channel = mock.Mock()
87+
88+
mock_otel = mock.Mock()
89+
mock_otel_grpc = mock_otel.instrumentation.grpc
90+
mock_interceptor = mock.Mock()
91+
92+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
93+
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
94+
95+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
96+
monkeypatch.setitem(
97+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
98+
)
99+
monkeypatch.setitem(
100+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
101+
)
102+
103+
result = _observability.apply_otel_capabilities_to_channel(
104+
mock_channel, client_options=options
105+
)
106+
107+
assert result is mock_intercepted_channel
108+
mock_otel_grpc.client_interceptor.assert_called_once_with(
109+
tracer_provider=mock_tracer_provider
110+
)
111+
mock_otel_grpc.intercept_channel.assert_called_once_with(
112+
mock_channel, mock_interceptor
113+
)
114+
115+
116+
def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch):
117+
# Tracing enabled via dict config
118+
mock_tracer_provider = object()
119+
options = {"tracer_provider": mock_tracer_provider}
120+
121+
mock_channel = mock.Mock()
122+
mock_intercepted_channel = mock.Mock()
123+
124+
mock_otel = mock.Mock()
125+
mock_otel_grpc = mock_otel.instrumentation.grpc
126+
mock_interceptor = mock.Mock()
127+
128+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
129+
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel
130+
131+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
132+
monkeypatch.setitem(
133+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
134+
)
135+
monkeypatch.setitem(
136+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
137+
)
138+
139+
result = _observability.apply_otel_capabilities_to_channel(
140+
mock_channel, client_options=options
141+
)
142+
143+
assert result is mock_intercepted_channel
144+
mock_otel_grpc.client_interceptor.assert_called_once_with(
145+
tracer_provider=mock_tracer_provider
146+
)
147+
mock_otel_grpc.intercept_channel.assert_called_once_with(
148+
mock_channel, mock_interceptor
149+
)

0 commit comments

Comments
 (0)