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..7f50b299708b --- /dev/null +++ b/packages/google-api-core/google/api_core/_otel_helpers.py @@ -0,0 +1,65 @@ +# -*- 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 | 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 or dict), and soft-importing + the `opentelemetry-instrumentation-grpc` package. + + Args: + client_options: The client options object or dictionary, 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] + + 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. + # 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/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") 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..550342a11269 --- /dev/null +++ b/packages/google-api-core/tests/unit/test_otel_helpers.py @@ -0,0 +1,105 @@ +# 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 + +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 + ) + + +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 + )