From 45ef7ac5a8f04ccef88aa776686368bb31b61358 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 08:00:49 -0400 Subject: [PATCH 1/4] feat(api-core): add tracer_provider to ClientOptions for OTel support --- packages/google-api-core/google/api_core/client_options.py | 4 ++++ packages/google-api-core/tests/unit/test_client_options.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/client_options.py b/packages/google-api-core/google/api_core/client_options.py index 68c4644245ce..ccf731fe6d8c 100644 --- a/packages/google-api-core/google/api_core/client_options.py +++ b/packages/google-api-core/google/api_core/client_options.py @@ -98,6 +98,8 @@ class ClientOptions(object): `googleapis.com`. If both `api_endpoint` and `universe_domain` are set, then `api_endpoint` is used as the service endpoint. If `api_endpoint` is not specified, the format will be `{service}.{universe_domain}`. + tracer_provider (Optional[object]): The OpenTelemetry tracer provider to use + for tracing. If not set, the global tracer provider will be used. Raises: ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source`` @@ -117,6 +119,7 @@ def __init__( api_key: Optional[str] = None, api_audience: Optional[str] = None, universe_domain: Optional[str] = None, + tracer_provider: Optional[object] = None, ): if credentials_file is not None: warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) @@ -136,6 +139,7 @@ def __init__( self.api_key = api_key self.api_audience = api_audience self.universe_domain = universe_domain + self.tracer_provider = tracer_provider def __repr__(self) -> str: return "ClientOptions: " + repr(self.__dict__) diff --git a/packages/google-api-core/tests/unit/test_client_options.py b/packages/google-api-core/tests/unit/test_client_options.py index 5d68232219f1..c15e83174ed4 100644 --- a/packages/google-api-core/tests/unit/test_client_options.py +++ b/packages/google-api-core/tests/unit/test_client_options.py @@ -15,7 +15,6 @@ from re import match import pytest - from google.api_core import client_options from ..helpers import warn_deprecated_credentials_file @@ -30,6 +29,7 @@ def get_client_encrypted_cert(): def test_constructor(): + mock_tracer_provider = object() with warn_deprecated_credentials_file(): options = client_options.ClientOptions( api_endpoint="foo.googleapis.com", @@ -42,6 +42,7 @@ def test_constructor(): ], api_audience="foo2.googleapis.com", universe_domain="googleapis.com", + tracer_provider=mock_tracer_provider, ) assert options.api_endpoint == "foo.googleapis.com" @@ -54,6 +55,7 @@ def test_constructor(): ] assert options.api_audience == "foo2.googleapis.com" assert options.universe_domain == "googleapis.com" + assert options.tracer_provider is mock_tracer_provider def test_constructor_with_encrypted_cert_source(): @@ -162,6 +164,7 @@ def test_repr(): "scopes", "api_key", "api_audience", + "tracer_provider", ] ) options = client_options.ClientOptions(api_endpoint="foo.googleapis.com") From 5a84a8ff9adf5b3712687d31776c1d63ad7c2469 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 09:40:25 -0400 Subject: [PATCH 2/4] feat(api-core): add _otel_helpers to centralize OTel interceptor resolution --- .../google/api_core/_otel_helpers.py | 61 ++++++++++++++ .../tests/unit/test_otel_helpers.py | 81 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/google-api-core/google/api_core/_otel_helpers.py create mode 100644 packages/google-api-core/tests/unit/test_otel_helpers.py diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py new file mode 100644 index 000000000000..836e086b5455 --- /dev/null +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""OpenTelemetry helpers for resolving and instantiating interceptors.""" + +from typing import Any, Optional + +from google.api_core import _feature_gating_helpers +from google.api_core.client_options import ClientOptions + + +def get_otel_grpc_interceptor( + client_options: Optional[ClientOptions] = None, + env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", +) -> Optional[Any]: + """Checks feature flags, attempts to import OTel, and returns the interceptor. + + This helper centralizes the logic for checking environment variables, + programmatic configuration (via ClientOptions), and soft-importing + the `opentelemetry-instrumentation-grpc` package. + + Args: + client_options: The client options object, potentially holding a tracer_provider. + env_var: The environment variable to check for enablement. + + Returns: + Optional[Any]: An OpenTelemetry gRPC client interceptor instance if enabled + and installed, otherwise None. + """ + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var=env_var, + feature_key="tracer_provider", + configuration=client_options, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + + tracer_provider = getattr(client_options, "tracer_provider", None) + return otel_grpc.client_interceptor(tracer_provider=tracer_provider) + except ImportError: + # Failed open if OTel is not installed but feature was requested. + # We might want to warn here, but for now we follow the "silent fail open" pattern + # or let the user handle it if they care. + pass + + return None diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py new file mode 100644 index 000000000000..42574343d94e --- /dev/null +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -0,0 +1,81 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from unittest import mock + +import pytest +from google.api_core import _otel_helpers +from google.api_core.client_options import ClientOptions + + +def test_get_otel_grpc_interceptor_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is None + + +def test_get_otel_grpc_interceptor_enabled_otel_missing(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + # Simulate OTel not being installed by blocking imports + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is None + + +def test_get_otel_grpc_interceptor_enabled_otel_installed(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + 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 + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor() + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None) + + +def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): + # Tracing enabled via config (tracer_provider is set) + 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 + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) From 343e4caaabbc003a5432fae9092f6eb0aeda5271 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 18 Aug 2026 13:07:58 -0400 Subject: [PATCH 3/4] feat(api-core): support dictionaries in _otel_helpers --- .../google/api_core/_otel_helpers.py | 12 ++++++--- .../tests/unit/test_otel_helpers.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_otel_helpers.py b/packages/google-api-core/google/api_core/_otel_helpers.py index 836e086b5455..7f50b299708b 100644 --- a/packages/google-api-core/google/api_core/_otel_helpers.py +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -23,17 +23,17 @@ def get_otel_grpc_interceptor( - client_options: Optional[ClientOptions] = None, + client_options: Optional[ClientOptions | dict[str, Any]] = None, env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", ) -> Optional[Any]: """Checks feature flags, attempts to import OTel, and returns the interceptor. This helper centralizes the logic for checking environment variables, - programmatic configuration (via ClientOptions), and soft-importing + programmatic configuration (via ClientOptions or dict), and soft-importing the `opentelemetry-instrumentation-grpc` package. Args: - client_options: The client options object, potentially holding a tracer_provider. + client_options: The client options object or dictionary, potentially holding a tracer_provider. env_var: The environment variable to check for enablement. Returns: @@ -50,7 +50,11 @@ def get_otel_grpc_interceptor( try: import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - tracer_provider = getattr(client_options, "tracer_provider", None) + if isinstance(client_options, dict): + tracer_provider = client_options.get("tracer_provider") + else: + tracer_provider = getattr(client_options, "tracer_provider", None) + return otel_grpc.client_interceptor(tracer_provider=tracer_provider) except ImportError: # Failed open if OTel is not installed but feature was requested. diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 42574343d94e..1cd53f32a655 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -79,3 +79,28 @@ def test_get_otel_grpc_interceptor_enabled_via_config(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider ) + + +def test_get_otel_grpc_interceptor_enabled_via_dict_config(monkeypatch): + # Tracing enabled via dict config + 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 + + 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 + ) + + interceptor = _otel_helpers.get_otel_grpc_interceptor(client_options=options) + assert interceptor is mock_interceptor + mock_otel_grpc.client_interceptor.assert_called_once_with( + tracer_provider=mock_tracer_provider + ) From 9fa7af9507f5a37c92b3ddcb4815e3b0f57495b4 Mon Sep 17 00:00:00 2001 From: Chalmer Lowe Date: Tue, 18 Aug 2026 13:16:21 -0400 Subject: [PATCH 4/4] Removed pytest. --- packages/google-api-core/tests/unit/test_otel_helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/google-api-core/tests/unit/test_otel_helpers.py b/packages/google-api-core/tests/unit/test_otel_helpers.py index 1cd53f32a655..550342a11269 100644 --- a/packages/google-api-core/tests/unit/test_otel_helpers.py +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -15,7 +15,6 @@ import sys from unittest import mock -import pytest from google.api_core import _otel_helpers from google.api_core.client_options import ClientOptions