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..724a720c82b0 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 TracerProvider to use + for tracing. If not set, the global tracer provider is 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/google/api_core/grpc_helpers.py b/packages/google-api-core/google/api_core/grpc_helpers.py index 263079e7d1f7..97574b91a1d4 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -25,8 +25,7 @@ import google.auth.transport.requests import google.protobuf import grpc - -from google.api_core import exceptions, general_helpers +from google.api_core import _feature_gating_helpers, exceptions, general_helpers # The list of gRPC Callable interfaces that return iterators. _STREAM_WRAP_CLASSES = (grpc.UnaryStreamMultiCallable, grpc.StreamStreamMultiCallable) @@ -384,10 +383,36 @@ def create_channel( if attempt_direct_path: target = _modify_target_for_direct_path(target) - return grpc.secure_channel( + configuration = kwargs.pop("configuration", None) + + channel = grpc.secure_channel( target, composite_credentials, compression=compression, **kwargs ) + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=configuration, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + tracer_provider = None + if configuration is not None: + if isinstance(configuration, dict): + tracer_provider = configuration.get("tracer_provider") + else: + tracer_provider = getattr(configuration, "tracer_provider", None) + + interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider) + channel = grpc.intercept_channel(channel, interceptor) + except ImportError: + # If OpenTelemetry gRPC instrumentation is missing, this should simply NOOP and fail open rather than failing import. + pass + + return channel + def _modify_target_for_direct_path(target: str) -> str: """ diff --git a/packages/google-api-core/google/api_core/grpc_helpers_async.py b/packages/google-api-core/google/api_core/grpc_helpers_async.py index d1f897901e7a..c002f6713786 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers_async.py +++ b/packages/google-api-core/google/api_core/grpc_helpers_async.py @@ -24,9 +24,8 @@ from typing import AsyncGenerator, Generic, Iterator, Optional, TypeVar import grpc -from grpc import aio - from google.api_core import exceptions, general_helpers, grpc_helpers +from grpc import aio # denotes the proto response type for grpc calls P = TypeVar("P") @@ -303,6 +302,15 @@ def create_channel( if attempt_direct_path: target = grpc_helpers._modify_target_for_direct_path(target) + # NOTE: 'configuration' is popped to prevent a TypeError. + # Generated async transports (like those in google-cloud-* libs) pass 'configuration' + # down to this helper via **kwargs to support tracing in sync transports. + # However, 'aio.secure_channel' does not recognize this parameter yet and will + # crash if it is passed through. + # Async gRPC tracing is deferred to a future phase/PR, so we simply discard + # this parameter for now to ensure generated async code doesn't fail at runtime. + kwargs.pop("configuration", None) + return aio.secure_channel( target, composite_credentials, compression=compression, **kwargs ) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 18113cb4de74..8c0a9558c28f 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.27.0, < 2.0.0", ] dynamic = ["version"] @@ -91,4 +92,6 @@ filterwarnings = [ "ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning", # Remove once https://github.com/grpc/grpc/issues/35086 is fixed (and version newer than 1.60.0 is published) "ignore:There is no current event loop:DeprecationWarning", + # Ignore external OpenTelemetry/importlib.metadata SelectableGroups warning + "ignore:.*SelectableGroups dict interface is deprecated:DeprecationWarning", ] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 5fb51afb6c56..4cb9760152c6 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,3 +12,4 @@ requests==2.33.0 grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 +opentelemetry-api==1.27.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..bd2beec5f247 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,4 @@ grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 aiohttp==3.13.4 +opentelemetry-api==1.27.0 diff --git a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py index dcb09f18fea2..bdc8b80ba8c8 100644 --- a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py +++ b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py @@ -33,7 +33,6 @@ import google.auth.credentials - from google.api_core import exceptions, grpc_helpers_async 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..632fd0740772 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 @@ -162,6 +161,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_grpc_helpers.py b/packages/google-api-core/tests/unit/test_grpc_helpers.py index 69281d58109b..39b533261c71 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -24,9 +24,8 @@ pytest.skip("No GRPC", allow_module_level=True) import google.auth.credentials -from google.longrunning import operations_pb2 - from google.api_core import exceptions, grpc_helpers +from google.longrunning import operations_pb2 def test__patch_callable_name(): diff --git a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py new file mode 100644 index 000000000000..0739e7fdef6f --- /dev/null +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -0,0 +1,123 @@ +# 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. + +"""Tests for OpenTelemetry gRPC interceptor integration in google-api-core.""" + +import sys +import types +from unittest import mock + +import pytest + +try: + from google.api_core import grpc_helpers + + HAS_GRPC_HELPERS = True +except ImportError: + HAS_GRPC_HELPERS = False + + +@pytest.fixture +def mock_otel_grpc(monkeypatch): + """Fixture to mock OpenTelemetry gRPC hierarchy.""" + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + + modules = { + "opentelemetry": mock_otel, + "opentelemetry.instrumentation": mock_otel.instrumentation, + "opentelemetry.instrumentation.grpc": mock_otel_grpc, + } + + for name, mod in modules.items(): + monkeypatch.setitem(sys.modules, name, mod) + + return mock_otel_grpc + + +@pytest.mark.parametrize( + "is_otel_installed, tracing_env_var_value, expect_otel_interceptor", + [ + pytest.param(True, "true", True, id="installed_and_enabled"), + pytest.param(True, "false", False, id="installed_but_disabled"), + pytest.param(False, "true", False, id="not_installed_fails_open"), + ], +) +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_otel_combos( + monkeypatch, + mock_otel_grpc, + is_otel_installed, + tracing_env_var_value, + expect_otel_interceptor, +): + """Verify create_channel behavior with various OTel installation and enablement states.""" + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", tracing_env_var_value) + + if not is_otel_installed: + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) + + mock_channel = "raw_channel" + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel, mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ) as mock_intercept_channel: + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): + channel = grpc_helpers.create_channel("localhost:1234") + + # Always expect raw channel creation + mock_secure_channel.assert_called_once() + + if expect_otel_interceptor: + mock_otel_grpc.client_interceptor.assert_called_once() + mock_intercept_channel.assert_called_once_with( + mock_channel, mock_otel_grpc.client_interceptor.return_value + ) + assert channel == f"wrapped_{mock_channel}" + else: + # OTel should NOT have been called + mock_intercept_channel.assert_not_called() + assert channel == mock_channel + + +@pytest.mark.parametrize( + "config_factory", + [ + lambda tp: {"tracer_provider": tp}, + lambda tp: types.SimpleNamespace(tracer_provider=tp), + ], + ids=["dict", "object"], +) +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +def test_create_channel_with_custom_tracer_provider(monkeypatch, mock_otel_grpc, config_factory): + """Verify that create_channel passes custom tracer_provider to OTel interceptor.""" + + mock_tracer_provider = mock.Mock() + config = config_factory(mock_tracer_provider) + + mock_channel = "raw_channel" + with mock.patch("grpc.secure_channel", return_value=mock_channel), mock.patch( + "grpc.intercept_channel", side_effect=lambda ch, inc: f"wrapped_{ch}" + ): + with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + grpc_helpers.create_channel("localhost:1234", configuration=config) + + mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=mock_tracer_provider)