diff --git a/mypy.ini b/mypy.ini index 19b239414608..ee6c090f7212 100644 --- a/mypy.ini +++ b/mypy.ini @@ -82,6 +82,11 @@ ignore_missing_imports = True ignore_missing_imports = True +# OpenTelemetry is an optional dependency and may not be installed in all test +# environments (e.g. to verify core functionality works without it). +[mypy-opentelemetry.*] +ignore_missing_imports = True + # ============================================================================== # PACKAGE-SPECIFIC OVERRIDES & EXCEPTIONS # ============================================================================== diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py new file mode 100644 index 000000000000..a36df8b39599 --- /dev/null +++ b/packages/google-api-core/google/api_core/_observability.py @@ -0,0 +1,89 @@ +# -*- 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 + +_TRACER_PROVIDER = "tracer_provider" + + +def is_otel_capabilities_enabled( + client_options: Optional[ClientOptions | dict[str, Any]] = None, + env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", +) -> bool: + """Checks if OTel capabilities are enabled and installed. + + Args: + client_options: The client options object or dictionary. + env_var: The environment variable to check for enablement. + + Returns: + bool: True if enabled and installed, False otherwise. + """ + 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] # noqa: F401 + + return True + except ImportError: + pass + + return False + + +def apply_otel_capabilities_to_channel( + channel: Any, + client_options: Optional[ClientOptions | dict[str, Any]] = None, +) -> 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. + + Args: + channel: The raw gRPC channel to wrap. + client_options: The client options object or dictionary. + + Returns: + Any: The intercepted channel. + + Raises: + ImportError: If OpenTelemetry packages are not installed and this function + is called directly (bypassing the precondition). + """ + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + + tracer_provider = None + if isinstance(client_options, dict): + tracer_provider = client_options.get(_TRACER_PROVIDER) + elif client_options is not None: + tracer_provider = getattr(client_options, _TRACER_PROVIDER, None) + + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + + # We use OTel's own compatible applier to avoid standard gRPC TypeError. + return otel_grpc.intercept_channel(channel, interceptor) 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..491e9a0bb372 100644 --- a/packages/google-api-core/google/api_core/client_options.py +++ b/packages/google-api-core/google/api_core/client_options.py @@ -48,9 +48,13 @@ def get_client_cert(): """ +import typing import warnings from typing import Callable, Mapping, Optional, Sequence, Tuple +if typing.TYPE_CHECKING: + import opentelemetry.trace + from google.api_core import general_helpers @@ -98,6 +102,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["opentelemetry.trace.TracerProvider"]): The OpenTelemetry tracer provider to use + for tracing in supported libraries. Raises: ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source`` @@ -117,6 +123,7 @@ def __init__( api_key: Optional[str] = None, api_audience: Optional[str] = None, universe_domain: Optional[str] = None, + tracer_provider: Optional["opentelemetry.trace.TracerProvider"] = None, ): if credentials_file is not None: warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) @@ -136,6 +143,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/pyproject.toml b/packages/google-api-core/pyproject.toml index 09038e43a9f3..9bff8ce994cb 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "proto-plus >= 1.26.1, < 2.0.0", "google-auth >= 2.14.1, < 3.0.0", "requests >= 2.33.0, < 3.0.0", + "opentelemetry-api >= 1.44.0, < 2.0.0", ] dynamic = ["version"] @@ -64,6 +65,12 @@ grpc = [ "grpcio-status >= 1.59.0, < 2.0.0", "grpcio-status >= 1.75.1, < 2.0.0; python_version >= '3.14'", ] +tracing = [ + "opentelemetry-instrumentation-grpc >= 0.65b0, < 1.0.0", +] +testing = [ + "opentelemetry-sdk >= 1.44.0, < 2.0.0", +] [tool.setuptools.dynamic] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 5fb51afb6c56..c6a0e6ef4930 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,3 +12,6 @@ requests==2.33.0 grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 +opentelemetry-api==1.44.0 +opentelemetry-instrumentation-grpc==0.65b0 +opentelemetry-sdk==1.44.0 diff --git a/packages/google-api-core/testing/constraints-async-rest-3.10.txt b/packages/google-api-core/testing/constraints-async-rest-3.10.txt index d94635253d59..5f33ca6752ea 100644 --- a/packages/google-api-core/testing/constraints-async-rest-3.10.txt +++ b/packages/google-api-core/testing/constraints-async-rest-3.10.txt @@ -13,3 +13,6 @@ grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 aiohttp==3.13.4 +opentelemetry-api==1.44.0 +opentelemetry-instrumentation-grpc==0.65b0 +opentelemetry-sdk==1.44.0 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_observability.py b/packages/google-api-core/tests/unit/test_observability.py new file mode 100644 index 000000000000..fc63023aadcd --- /dev/null +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -0,0 +1,149 @@ +# 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 _observability +from google.api_core.client_options import ClientOptions + + +def test_is_otel_capabilities_enabled_disabled(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_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") + # Simulate OTel not being installed by blocking imports + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + assert not _observability.is_otel_capabilities_enabled() + + +def test_is_otel_capabilities_enabled_otel_installed(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_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 + ) + + assert _observability.is_otel_capabilities_enabled() + + +def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch): + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() + + 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( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + result = _observability.apply_otel_capabilities_to_channel(mock_channel) + + 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_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch): + # Tracing enabled via config (tracer_provider is set) + mock_tracer_provider = object() + options = ClientOptions(tracer_provider=mock_tracer_provider) + + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() + + 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( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + result = _observability.apply_otel_capabilities_to_channel( + mock_channel, client_options=options + ) + + assert result is mock_intercepted_channel + 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 + ) + + +def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch): + # Tracing enabled via dict config + mock_tracer_provider = object() + options = {"tracer_provider": mock_tracer_provider} + + mock_channel = mock.Mock() + mock_intercepted_channel = mock.Mock() + + 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( + sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation + ) + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) + + result = _observability.apply_otel_capabilities_to_channel( + mock_channel, client_options=options + ) + + assert result is mock_intercepted_channel + 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 + )