From 38167cf7e9fd5e369e39b41af5e862e126992e62 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 11:21:36 -0400 Subject: [PATCH 01/16] feat: Add OpenTelemetry environment variable and options configuration helpers --- .../google/api_core/observability/__init__.py | 3 + .../google/api_core/observability/options.py | 110 ++++++++++++++++++ .../tests/unit/observability/test_options.py | 70 +++++++++++ 3 files changed, 183 insertions(+) create mode 100644 packages/google-api-core/google/api_core/observability/__init__.py create mode 100644 packages/google-api-core/google/api_core/observability/options.py create mode 100644 packages/google-api-core/tests/unit/observability/test_options.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py new file mode 100644 index 000000000000..f4144485e5f9 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -0,0 +1,3 @@ +from .options import is_signal_enabled + +__all__ = ["is_signal_enabled"] diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py new file mode 100644 index 000000000000..69aabdecfd96 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -0,0 +1,110 @@ +"""Observability environment variable and client options resolution helpers.""" + +import os +import warnings +from typing import Any, Dict, List, Optional, Union + +# Allowed truthy and falsy patterns for environment variables +_TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") +_FALSY_VALUES = ("n", "no", "f", "false", "off", "0") + + +def _strtobool(val: str) -> Optional[bool]: + """Convert a string representation of truth to a boolean.""" + clean_val = val.lower().strip() + if not clean_val: + return None + if clean_val in _TRUTHY_VALUES: + return True + if clean_val in _FALSY_VALUES: + return False + raise ValueError(f"Invalid truth value: {val!r}") + + +def _get_env_bool(name: str) -> Optional[bool]: + """Retrieve the boolean value of an environment variable.""" + val = os.getenv(name) + if val is None: + return None + try: + return _strtobool(val) + except ValueError: + return None + + +def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: + """Retrieve the boolean value of an environment variable, checking dev/exp fallbacks first.""" + if name.startswith("GOOGLE_CLOUD_"): + exp_name = name.replace("GOOGLE_CLOUD_", "GOOGLE_CLOUD_EXPERIMENTAL_", 1) + val = _get_env_bool(exp_name) + if val is not None: + return val + return _get_env_bool(name) + + +def is_signal_enabled( + service_name: str, + signal_type: str, + client_options: Optional[Union[Dict[str, Any], Any]] = None, + default: bool = False, + legacy_vars: Optional[List[str]] = None, +) -> bool: + """Determines if a telemetry signal is enabled.""" + service_upper = service_name.upper().replace("-", "_") + signal_upper = signal_type.upper() + + # 1. Resolve Programmatic Options First + if client_options is not None: + options_dict = ( + client_options + if isinstance(client_options, dict) + else getattr(client_options, "__dict__", {}) + ) + option_key = f"enable_{signal_type.lower()}" + provider_key = f"{signal_type.rstrip('s').lower()}_provider" + + if options_dict.get(option_key) is not None: + return bool(options_dict.get(option_key)) + if options_dict.get(provider_key) is not None: + return True + + # 2. Language & Service-specific + val = _get_env_bool_with_dev_fallback( + f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED" + ) + if val is not None: + return val + + # 3. Language-wide Global + val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_PYTHON_{signal_upper}_ENABLED") + if val is not None: + return val + + # 4. Cross-language Service-specific + val = _get_env_bool_with_dev_fallback( + f"GOOGLE_CLOUD_{service_upper}_{signal_upper}_ENABLED" + ) + if val is not None: + return val + + # 5. Cross-language Global + val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_{signal_upper}_ENABLED") + if val is not None: + return val + + # 6. Legacy Variables + if legacy_vars: + for legacy_var in legacy_vars: + val = _get_env_bool(legacy_var) + if val is not None: + warnings.warn( + f"Environment variable {legacy_var!r} is deprecated and will be removed " + "in a future release. Please migrate to the standardized " + f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED instead.", + DeprecationWarning, + stacklevel=2, + ) + return val + + # 7. Default Fallback + return default diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py new file mode 100644 index 000000000000..494d2ae8816a --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -0,0 +1,70 @@ +import pytest + +from google.api_core.observability import options + + +@pytest.mark.parametrize( + "env_vars, client_options, default_val, expected", + [ + # Default fallback tests + ({}, None, False, False), + ({}, None, True, True), + # Service-specific env var + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, None, False, True), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, None, True, False), + # Experimental fallback + ( + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, + None, + False, + True, + ), + # Precedence: Service specific overrides global + ( + { + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "true", + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false", + }, + None, + False, + False, + ), + ( + { + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "false", + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true", + }, + None, + False, + True, + ), + # Precedence: Client options override env vars + ( + {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, + {"enable_traces": True}, + False, + True, + ), + ], +) +def test_is_signal_enabled( + monkeypatch, env_vars, client_options, default_val, expected +): + # Setup environment variables using pytest's monkeypatch fixture + for k, v in env_vars.items(): + monkeypatch.setenv(k, v) + + result = options.is_signal_enabled( + "translate", "traces", client_options=client_options, default=default_val + ) + assert result is expected + + +def test_legacy_var_with_warning(monkeypatch): + monkeypatch.setenv("LEGACY_TRACE_VAR", "true") + + with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): + result = options.is_signal_enabled( + "translate", "traces", legacy_vars=["LEGACY_TRACE_VAR"] + ) + assert result is True From 2db2cf1ba3b1f49777c213419c8a74f88ebc81b6 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 14:18:20 -0400 Subject: [PATCH 02/16] feat(observability): add base OpenTelemetry span enricher interceptor --- .../google/api_core/observability/__init__.py | 8 +- .../google/api_core/observability/tracing.py | 75 ++++++++ packages/google-api-core/pyproject.toml | 1 + .../testing/constraints-3.10.txt | 1 + .../testing/constraints-async-rest-3.10.txt | 1 + .../tests/unit/observability/test_tracing.py | 160 ++++++++++++++++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 packages/google-api-core/google/api_core/observability/tracing.py create mode 100644 packages/google-api-core/tests/unit/observability/test_tracing.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index f4144485e5f9..2059d1f16d34 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,3 +1,9 @@ from .options import is_signal_enabled -__all__ = ["is_signal_enabled"] +try: + # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. + from .tracing import OtelSpanEnricher # noqa: F401 + + __all__ = ["is_signal_enabled", "OtelSpanEnricher"] +except ImportError: + __all__ = ["is_signal_enabled"] diff --git a/packages/google-api-core/google/api_core/observability/tracing.py b/packages/google-api-core/google/api_core/observability/tracing.py new file mode 100644 index 000000000000..9f26b9e7dc62 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/tracing.py @@ -0,0 +1,75 @@ +# 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 Tracing Enrichment Interceptors.""" + +from typing import Any, Callable, Dict, Optional + +import grpc +from opentelemetry import trace + + +class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor): + """A gRPC client interceptor that enriches the active OpenTelemetry span. + + This interceptor relies on the standard OpenTelemetry gRPC instrumentor + to create the baseline span. It runs in the interceptor chain to inject + additional Google Cloud specific domain attributes. + """ + + def __init__( + self, + static_attributes: Optional[Dict[str, Any]] = None, + attribute_extractor: Optional[ + Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]] + ] = None, + ): + """Initializes the OtelSpanEnricher. + + Args: + static_attributes: Standard static attributes to attach to every span. + E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} + attribute_extractor: A callable that extracts dynamic attributes from + the request and client call details. + """ + self._static_attributes = static_attributes or {} + self._attribute_extractor = attribute_extractor + + def intercept_unary_unary( + self, + continuation: Callable[[grpc.ClientCallDetails, Any], Any], + client_call_details: grpc.ClientCallDetails, + request: Any, + ) -> Any: + span = trace.get_current_span() + + if span.is_recording(): + # Inject static attributes + for key, val in self._static_attributes.items(): + span.set_attribute(key, val) + + # Extract and inject dynamic attributes + if self._attribute_extractor: + try: + dynamic_attrs = self._attribute_extractor( + request, client_call_details + ) + for key, val in dynamic_attrs.items(): + if val is not None: + span.set_attribute(key, val) + except Exception: + # Prevent custom extractor exceptions from failing the RPC + pass + + return continuation(client_call_details, request) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 18113cb4de74..b2c622e69bf4 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.1.0, < 2.0.0", ] dynamic = ["version"] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 5fb51afb6c56..f771e33e0c3d 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.1.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..504369785439 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.1.0 diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py new file mode 100644 index 000000000000..d1ba00802480 --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -0,0 +1,160 @@ +# 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. + +from unittest.mock import MagicMock, Mock + +import pytest + +# Check if grpc is available +try: + import grpc + + has_grpc = True +except ImportError: + has_grpc = False + +# Skip all tests in this module if grpc is not installed +pytestmark = pytest.mark.skipif(not has_grpc, reason="grpc package is required") + +if has_grpc: + + class MockClientCallDetails(grpc.ClientCallDetails): + pass + +else: + # Tell mypy that we are intentionally redefining this class for the non-gRPC fallback path. + class MockClientCallDetails: # type: ignore[no-redef] + pass + + +@pytest.fixture +def mock_span(mocker): + """Mocks trace.get_current_span to return a recording span.""" + mock_span_obj = MagicMock() + mock_span_obj.is_recording.return_value = True + mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) + return mock_span_obj + + +@pytest.fixture +def mock_span_non_recording(mocker): + """Mocks trace.get_current_span to return a non-recording span.""" + mock_span_obj = MagicMock() + mock_span_obj.is_recording.return_value = False + mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) + return mock_span_obj + + +def test_enricher_non_recording_span(mock_span_non_recording): + """Verifies that non-recording spans do not have attributes set and extractor is skipped.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + extractor = Mock() + enricher = OtelSpanEnricher( + static_attributes={"static.key": "static.val"}, attribute_extractor=extractor + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request" + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + mock_span_non_recording.set_attribute.assert_not_called() + extractor.assert_not_called() + + +@pytest.mark.parametrize( + "static_attrs,request_val,extractor_return,expected_attrs", + [ + # Case 1: Only static attributes + ({"static.key": "static.val"}, "req", None, {"static.key": "static.val"}), + # Case 2: Only dynamic attributes + (None, "req", {"dynamic.key": "dynamic.val"}, {"dynamic.key": "dynamic.val"}), + # Case 3: Both static and dynamic + ( + {"static.key": "static.val"}, + "req", + {"dynamic.key": "dynamic.val"}, + {"static.key": "static.val", "dynamic.key": "dynamic.val"}, + ), + # Case 4: Dynamic extractor returns None values (should be skipped) + ( + {"static.key": "static.val"}, + "req", + {"dynamic.key": None, "other.key": "other.val"}, + {"static.key": "static.val", "other.key": "other.val"}, + ), + ], +) +def test_enricher_recording_span( + mock_span, static_attrs, request_val, extractor_return, expected_attrs +): + """Verifies static and dynamic attribute resolution on recording spans.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + if extractor_return is not None: + extractor = Mock(return_value=extractor_return) + else: + extractor = None + + enricher = OtelSpanEnricher( + static_attributes=static_attrs, attribute_extractor=extractor + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = request_val + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + + # Check that expected attributes were set + for key, val in expected_attrs.items(): + mock_span.set_attribute.assert_any_call(key, val) + + # Total set_attribute calls should match expected_attrs size + assert mock_span.set_attribute.call_count == len(expected_attrs) + + if extractor: + extractor.assert_called_once_with(request, details) + + +def test_enricher_extractor_exception(mock_span): + """Verifies that exceptions in attribute extraction are caught and do not fail the call.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + def bad_extractor(req, details): + raise ValueError("Extraction failure") + + enricher = OtelSpanEnricher( + static_attributes={"static.key": "static.val"}, + attribute_extractor=bad_extractor, + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "req" + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + + # Static attributes should still be set before extractor failure + mock_span.set_attribute.assert_called_once_with("static.key", "static.val") From dd070254061bfd8f84dd5110cabedb3e83f6219d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 15:02:55 -0400 Subject: [PATCH 03/16] test(observability): add test-only environment variable overrides and refactor tests --- .../google/api_core/observability/__init__.py | 19 +++- .../google/api_core/observability/options.py | 23 +++++ .../tests/unit/observability/test_options.py | 94 ++++++++++++++++--- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index 2059d1f16d34..46f4d5b4a0dc 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,9 +1,22 @@ -from .options import is_signal_enabled +from .options import ( + clear_test_env_overrides, + is_signal_enabled, + set_test_env_override, +) try: # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. from .tracing import OtelSpanEnricher # noqa: F401 - __all__ = ["is_signal_enabled", "OtelSpanEnricher"] + __all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", + "OtelSpanEnricher", + ] except ImportError: - __all__ = ["is_signal_enabled"] + __all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", + ] diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py index 69aabdecfd96..c8c0890bf05d 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -21,8 +21,31 @@ def _strtobool(val: str) -> Optional[bool]: raise ValueError(f"Invalid truth value: {val!r}") +_TEST_ENV_OVERRIDES: Dict[str, bool] = {} + + +def set_test_env_override(name: str, value: Optional[bool]) -> None: + """Sets a test-only override for a specific environment variable. + + This is intended ONLY for unit/integration testing to prevent mutating + os.environ. + """ + if value is None: + _TEST_ENV_OVERRIDES.pop(name, None) + else: + _TEST_ENV_OVERRIDES[name] = value + + +def clear_test_env_overrides() -> None: + """Clears all test-only overrides.""" + _TEST_ENV_OVERRIDES.clear() + + def _get_env_bool(name: str) -> Optional[bool]: """Retrieve the boolean value of an environment variable.""" + if name in _TEST_ENV_OVERRIDES: + return _TEST_ENV_OVERRIDES[name] + val = os.getenv(name) if val is None: return None diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index 494d2ae8816a..740f7278eda1 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -1,6 +1,72 @@ +# 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 pytest from google.api_core.observability import options +from google.api_core.observability.options import ( + _get_env_bool, + _strtobool, + clear_test_env_overrides, + set_test_env_override, +) + + +@pytest.fixture(autouse=True) +def clean_overrides(): + yield + clear_test_env_overrides() + + +@pytest.mark.parametrize( + "value,expected", + [ + ("y", True), + ("yes", True), + ("t", True), + ("true", True), + ("on", True), + ("1", True), + ("n", False), + ("no", False), + ("f", False), + ("false", False), + ("off", False), + ("0", False), + (" True ", True), + (" FALSE ", False), + ("", None), + ], +) +def test_strtobool(value, expected): + assert _strtobool(value) is expected + + +def test_strtobool_invalid(): + with pytest.raises(ValueError): + _strtobool("invalid") + + +def test_get_env_bool(monkeypatch): + monkeypatch.setenv("TEST_VAR", "true") + assert _get_env_bool("TEST_VAR") is True + + monkeypatch.setenv("TEST_VAR", "invalid") + assert _get_env_bool("TEST_VAR") is None + + monkeypatch.delenv("TEST_VAR", raising=False) + assert _get_env_bool("TEST_VAR") is None @pytest.mark.parametrize( @@ -10,11 +76,11 @@ ({}, None, False, False), ({}, None, True, True), # Service-specific env var - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, None, False, True), - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, None, True, False), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, None, True, False), # Experimental fallback ( - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True, @@ -22,8 +88,8 @@ # Precedence: Service specific overrides global ( { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "true", - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false", + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": True, + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False, }, None, False, @@ -31,8 +97,8 @@ ), ( { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "false", - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true", + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": False, + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True, }, None, False, @@ -40,19 +106,17 @@ ), # Precedence: Client options override env vars ( - {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, + {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, {"enable_traces": True}, False, True, ), ], ) -def test_is_signal_enabled( - monkeypatch, env_vars, client_options, default_val, expected -): - # Setup environment variables using pytest's monkeypatch fixture +def test_is_signal_enabled(env_vars, client_options, default_val, expected): + # Setup environment variables using our test overrides for k, v in env_vars.items(): - monkeypatch.setenv(k, v) + set_test_env_override(k, v) result = options.is_signal_enabled( "translate", "traces", client_options=client_options, default=default_val @@ -60,8 +124,8 @@ def test_is_signal_enabled( assert result is expected -def test_legacy_var_with_warning(monkeypatch): - monkeypatch.setenv("LEGACY_TRACE_VAR", "true") +def test_legacy_var_with_warning(): + set_test_env_override("LEGACY_TRACE_VAR", True) with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): result = options.is_signal_enabled( From 65dc83ee2a58e79f4024507f31989ff4c6fcd130 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 09:23:31 -0400 Subject: [PATCH 04/16] feat(observability): simplify options resolver to tracing-only --- .../google/api_core/observability/options.py | 73 +++++++------------ packages/google-api-core/pyproject.toml | 4 +- .../testing/constraints-3.10.txt | 2 +- .../testing/constraints-async-rest-3.10.txt | 2 +- .../tests/unit/observability/test_options.py | 64 ++++++++-------- 5 files changed, 63 insertions(+), 82 deletions(-) diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py index c8c0890bf05d..b4141424b12b 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -1,8 +1,7 @@ """Observability environment variable and client options resolution helpers.""" import os -import warnings -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional, Union # Allowed truthy and falsy patterns for environment variables _TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") @@ -66,15 +65,30 @@ def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: def is_signal_enabled( - service_name: str, signal_type: str, client_options: Optional[Union[Dict[str, Any], Any]] = None, default: bool = False, - legacy_vars: Optional[List[str]] = None, ) -> bool: - """Determines if a telemetry signal is enabled.""" - service_upper = service_name.upper().replace("-", "_") - signal_upper = signal_type.upper() + """Determines if a telemetry signal is enabled. + + Resolves settings in the following order of precedence: + 1. Programmatic overrides in client_options (checks tracer_provider) + 2. Language-wide Environment Variable: GOOGLE_CLOUD_PYTHON_TRACING_ENABLED + (natively checks for an EXPERIMENTAL prefix variant first) + 3. Default fallback + + Args: + signal_type: The signal type: must be 'tracing'. + client_options: A dictionary or object representing client configuration. + default: Fallback boolean if no options or env variables match. + + Returns: + bool: True if the signal is resolved to enabled, False otherwise. + """ + if signal_type != "tracing": + raise ValueError( + f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported." + ) # 1. Resolve Programmatic Options First if client_options is not None: @@ -83,51 +97,14 @@ def is_signal_enabled( if isinstance(client_options, dict) else getattr(client_options, "__dict__", {}) ) - option_key = f"enable_{signal_type.lower()}" - provider_key = f"{signal_type.rstrip('s').lower()}_provider" - if options_dict.get(option_key) is not None: - return bool(options_dict.get(option_key)) - if options_dict.get(provider_key) is not None: + if options_dict.get("tracer_provider") is not None: return True - # 2. Language & Service-specific - val = _get_env_bool_with_dev_fallback( - f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED" - ) - if val is not None: - return val - - # 3. Language-wide Global - val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_PYTHON_{signal_upper}_ENABLED") - if val is not None: - return val - - # 4. Cross-language Service-specific - val = _get_env_bool_with_dev_fallback( - f"GOOGLE_CLOUD_{service_upper}_{signal_upper}_ENABLED" - ) - if val is not None: - return val - - # 5. Cross-language Global - val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_{signal_upper}_ENABLED") + # 2. Check Language-Wide Environment Variable + val = _get_env_bool_with_dev_fallback("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED") if val is not None: return val - # 6. Legacy Variables - if legacy_vars: - for legacy_var in legacy_vars: - val = _get_env_bool(legacy_var) - if val is not None: - warnings.warn( - f"Environment variable {legacy_var!r} is deprecated and will be removed " - "in a future release. Please migrate to the standardized " - f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED instead.", - DeprecationWarning, - stacklevel=2, - ) - return val - - # 7. Default Fallback + # 3. Default Fallback return default diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index b2c622e69bf4..8c0a9558c28f 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -48,7 +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.1.0, < 2.0.0", + "opentelemetry-api >= 1.27.0, < 2.0.0", ] dynamic = ["version"] @@ -92,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 f771e33e0c3d..4cb9760152c6 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,4 +12,4 @@ requests==2.33.0 grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 -opentelemetry-api==1.1.0 +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 504369785439..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,4 +13,4 @@ grpcio==1.59.0 grpcio-status==1.59.0 proto-plus==1.26.1 aiohttp==3.13.4 -opentelemetry-api==1.1.0 +opentelemetry-api==1.27.0 diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index 740f7278eda1..44e952af366d 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -70,65 +70,67 @@ def test_get_env_bool(monkeypatch): @pytest.mark.parametrize( - "env_vars, client_options, default_val, expected", + "signal_type, env_vars, client_options, default_val, expected", [ # Default fallback tests - ({}, None, False, False), - ({}, None, True, True), - # Service-specific env var - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True), - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, None, True, False), + ("tracing", {}, None, False, False), + ("tracing", {}, None, True, True), + # Global env var + ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": True}, None, False, True), + ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, None, True, False), # Experimental fallback ( - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": True}, + "tracing", + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, None, False, True, ), - # Precedence: Service specific overrides global ( - { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": True, - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False, - }, + "tracing", + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": False}, None, - False, + True, False, ), + # Implicit opt-in with provider ( - { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": False, - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True, - }, - None, + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"tracer_provider": object()}, False, True, ), - # Precedence: Client options override env vars + # Programmatic boolean flags are NOT supported (should default/fallback) ( - {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, {"enable_traces": True}, False, - True, + False, + ), + ( + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"enable_tracing": True}, + False, + False, ), ], ) -def test_is_signal_enabled(env_vars, client_options, default_val, expected): +def test_is_signal_enabled( + signal_type, env_vars, client_options, default_val, expected +): # Setup environment variables using our test overrides for k, v in env_vars.items(): set_test_env_override(k, v) result = options.is_signal_enabled( - "translate", "traces", client_options=client_options, default=default_val + signal_type, client_options=client_options, default=default_val ) assert result is expected -def test_legacy_var_with_warning(): - set_test_env_override("LEGACY_TRACE_VAR", True) - - with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): - result = options.is_signal_enabled( - "translate", "traces", legacy_vars=["LEGACY_TRACE_VAR"] - ) - assert result is True +def test_is_signal_enabled_invalid_signal(): + with pytest.raises(ValueError, match="Only 'tracing' is supported"): + options.is_signal_enabled("traces") From f91ee4937b45323111f12d860212873f72cc1774 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 10:58:54 -0400 Subject: [PATCH 05/16] feat(api-core): implement OtelUnaryClientInterceptor and feature gating Refactored OtelSpanEnricher to OtelUnaryClientInterceptor to act as a span creator (Pure API approach). Integrated feature gating helpers to control tracing. Added dynamic attribute extraction from gRPC metadata. Cleaned up dead code and added samples. --- .../google/api_core/observability/__init__.py | 19 +- .../google/api_core/observability/options.py | 110 --------- .../google/api_core/observability/tracing.py | 87 ++++--- .../samples/sample_auto_translate_trace.py | 52 ++++ .../samples/sample_translate_trace.py | 41 ++++ .../tests/unit/observability/test_options.py | 136 ----------- .../tests/unit/observability/test_tracing.py | 230 ++++++++++-------- 7 files changed, 278 insertions(+), 397 deletions(-) delete mode 100644 packages/google-api-core/google/api_core/observability/options.py create mode 100644 packages/google-api-core/samples/sample_auto_translate_trace.py create mode 100644 packages/google-api-core/samples/sample_translate_trace.py delete mode 100644 packages/google-api-core/tests/unit/observability/test_options.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index 46f4d5b4a0dc..aea71d26d164 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,22 +1,9 @@ -from .options import ( - clear_test_env_overrides, - is_signal_enabled, - set_test_env_override, -) - try: # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. - from .tracing import OtelSpanEnricher # noqa: F401 + from .tracing import OtelUnaryClientInterceptor # noqa: F401 __all__ = [ - "is_signal_enabled", - "set_test_env_override", - "clear_test_env_overrides", - "OtelSpanEnricher", + "OtelUnaryClientInterceptor", ] except ImportError: - __all__ = [ - "is_signal_enabled", - "set_test_env_override", - "clear_test_env_overrides", - ] + __all__ = [] diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py deleted file mode 100644 index b4141424b12b..000000000000 --- a/packages/google-api-core/google/api_core/observability/options.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Observability environment variable and client options resolution helpers.""" - -import os -from typing import Any, Dict, Optional, Union - -# Allowed truthy and falsy patterns for environment variables -_TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") -_FALSY_VALUES = ("n", "no", "f", "false", "off", "0") - - -def _strtobool(val: str) -> Optional[bool]: - """Convert a string representation of truth to a boolean.""" - clean_val = val.lower().strip() - if not clean_val: - return None - if clean_val in _TRUTHY_VALUES: - return True - if clean_val in _FALSY_VALUES: - return False - raise ValueError(f"Invalid truth value: {val!r}") - - -_TEST_ENV_OVERRIDES: Dict[str, bool] = {} - - -def set_test_env_override(name: str, value: Optional[bool]) -> None: - """Sets a test-only override for a specific environment variable. - - This is intended ONLY for unit/integration testing to prevent mutating - os.environ. - """ - if value is None: - _TEST_ENV_OVERRIDES.pop(name, None) - else: - _TEST_ENV_OVERRIDES[name] = value - - -def clear_test_env_overrides() -> None: - """Clears all test-only overrides.""" - _TEST_ENV_OVERRIDES.clear() - - -def _get_env_bool(name: str) -> Optional[bool]: - """Retrieve the boolean value of an environment variable.""" - if name in _TEST_ENV_OVERRIDES: - return _TEST_ENV_OVERRIDES[name] - - val = os.getenv(name) - if val is None: - return None - try: - return _strtobool(val) - except ValueError: - return None - - -def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: - """Retrieve the boolean value of an environment variable, checking dev/exp fallbacks first.""" - if name.startswith("GOOGLE_CLOUD_"): - exp_name = name.replace("GOOGLE_CLOUD_", "GOOGLE_CLOUD_EXPERIMENTAL_", 1) - val = _get_env_bool(exp_name) - if val is not None: - return val - return _get_env_bool(name) - - -def is_signal_enabled( - signal_type: str, - client_options: Optional[Union[Dict[str, Any], Any]] = None, - default: bool = False, -) -> bool: - """Determines if a telemetry signal is enabled. - - Resolves settings in the following order of precedence: - 1. Programmatic overrides in client_options (checks tracer_provider) - 2. Language-wide Environment Variable: GOOGLE_CLOUD_PYTHON_TRACING_ENABLED - (natively checks for an EXPERIMENTAL prefix variant first) - 3. Default fallback - - Args: - signal_type: The signal type: must be 'tracing'. - client_options: A dictionary or object representing client configuration. - default: Fallback boolean if no options or env variables match. - - Returns: - bool: True if the signal is resolved to enabled, False otherwise. - """ - if signal_type != "tracing": - raise ValueError( - f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported." - ) - - # 1. Resolve Programmatic Options First - if client_options is not None: - options_dict = ( - client_options - if isinstance(client_options, dict) - else getattr(client_options, "__dict__", {}) - ) - - if options_dict.get("tracer_provider") is not None: - return True - - # 2. Check Language-Wide Environment Variable - val = _get_env_bool_with_dev_fallback("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED") - if val is not None: - return val - - # 3. Default Fallback - return default diff --git a/packages/google-api-core/google/api_core/observability/tracing.py b/packages/google-api-core/google/api_core/observability/tracing.py index 9f26b9e7dc62..64a01887ba10 100644 --- a/packages/google-api-core/google/api_core/observability/tracing.py +++ b/packages/google-api-core/google/api_core/observability/tracing.py @@ -20,31 +20,24 @@ from opentelemetry import trace -class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor): - """A gRPC client interceptor that enriches the active OpenTelemetry span. +class OtelUnaryClientInterceptor(grpc.UnaryUnaryClientInterceptor): + """A gRPC client interceptor that creates OpenTelemetry spans for outgoing requests. - This interceptor relies on the standard OpenTelemetry gRPC instrumentor - to create the baseline span. It runs in the interceptor chain to inject - additional Google Cloud specific domain attributes. + This interceptor explicitly creates a standard SpanKind.CLIENT span for each network attempt + and enriches it with standard Google Cloud attributes. """ def __init__( self, static_attributes: Optional[Dict[str, Any]] = None, - attribute_extractor: Optional[ - Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]] - ] = None, ): - """Initializes the OtelSpanEnricher. + """Initializes the OtelUnaryClientInterceptor. Args: static_attributes: Standard static attributes to attach to every span. E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} - attribute_extractor: A callable that extracts dynamic attributes from - the request and client call details. """ self._static_attributes = static_attributes or {} - self._attribute_extractor = attribute_extractor def intercept_unary_unary( self, @@ -52,24 +45,52 @@ def intercept_unary_unary( client_call_details: grpc.ClientCallDetails, request: Any, ) -> Any: - span = trace.get_current_span() - - if span.is_recording(): - # Inject static attributes - for key, val in self._static_attributes.items(): - span.set_attribute(key, val) - - # Extract and inject dynamic attributes - if self._attribute_extractor: - try: - dynamic_attrs = self._attribute_extractor( - request, client_call_details - ) - for key, val in dynamic_attrs.items(): - if val is not None: - span.set_attribute(key, val) - except Exception: - # Prevent custom extractor exceptions from failing the RPC - pass - - return continuation(client_call_details, request) + from google.api_core._feature_gating_helpers import resolve_feature_flags + + # For now, we only check environment variables as we don't have access to ClientOptions here. + # To support programmatic configuration, we would need to pass it during Client + # initialization. + # TODO: we need to refactor resolve_feature_flags to allows feature_key to be optional. + enabled = resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + ) + + if not enabled: + return continuation(client_call_details, request) + + tracer = trace.get_tracer(__name__) + + # Determine span name (e.g., from client_call_details.method) + span_name = client_call_details.method + + with tracer.start_as_current_span( + span_name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + # Inject static attributes + for key, val in self._static_attributes.items(): + span.set_attribute(key, val) + + # Extract dynamic attributes from metadata + for key, value in client_call_details.metadata: + if key == "x-goog-request-params": + try: + # x-goog-request-params is urlencoded string of key=value pairs separated by & + params = dict( + p.split("=") for p in value.split("&") if "=" in p + ) + + # Standard resource identifiers are usually in 'name' or 'parent' + resource_id = params.get("name") or params.get("parent") + if resource_id: + span.set_attribute( + "gcp.resource.destination.id", resource_id + ) + except Exception: + # Fail open if parsing fails to avoid breaking the request + pass + + span.set_attribute("rpc.system.name", "grpc") + + return continuation(client_call_details, request) diff --git a/packages/google-api-core/samples/sample_auto_translate_trace.py b/packages/google-api-core/samples/sample_auto_translate_trace.py new file mode 100644 index 000000000000..ab560b243063 --- /dev/null +++ b/packages/google-api-core/samples/sample_auto_translate_trace.py @@ -0,0 +1,52 @@ +import os + +from google.cloud import translate_v3 +from google.cloud.translate_v3.types import translation_service + +# 🚀 1. ACTIVATE MONKEY PATCHING (Auto-Instrumentation) +# This reaches into the gRPC library and wraps standard functions dynamically. +from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient + +GrpcInstrumentorClient().instrument() +print("✅ gRPC Client Auto-Instrumentation activated!") + +# 2. Standard OTel SDK Setup (Same as before, so we can see the console output) +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +print("Initializing TracerProvider...") +provider = TracerProvider() +exporter = ConsoleSpanExporter() +provider.add_span_processor(SimpleSpanProcessor(exporter)) +trace.set_tracer_provider(provider) +print("TracerProvider initialized.") + +# 3. Instantiate Client (Standard GAPIC, NO manual instrumentation used here) +print("Instantiating TranslationServiceClient...") +client = translate_v3.TranslationServiceClient() +print("TranslationServiceClient instantiated.") + +# 4. Create Request +project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian") +parent = f"projects/{project_id}/locations/global" + +request = translation_service.TranslateTextRequest( + contents=["Hello, world!", "OpenTelemetry is braw!"], + target_language_code="es", + source_language_code="en", + model=f"{parent}/models/general/nmt", + mime_type="text/plain", + parent=parent, +) + +# 5. Call API +print("Sending translate request...") +try: + response = client.translate_text(request) + print("Translation Response received.") + print(f"Translated text: {response.translations[0].translated_text}") +except Exception as e: + print(f"API Call failed: {e}") + +print("Done. Check console output for traces.") diff --git a/packages/google-api-core/samples/sample_translate_trace.py b/packages/google-api-core/samples/sample_translate_trace.py new file mode 100644 index 000000000000..e52bde13909e --- /dev/null +++ b/packages/google-api-core/samples/sample_translate_trace.py @@ -0,0 +1,41 @@ +import os + +from google.cloud import translate_v3 +from google.cloud.translate_v3.types import translation_service +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +# 1. Setup OTel with Console Exporter +provider = TracerProvider() +exporter = ConsoleSpanExporter() +provider.add_span_processor(SimpleSpanProcessor(exporter)) +trace.set_tracer_provider(provider) + +# 2. Instantiate Client +# Using standard Application Default Credentials (ADC). +client = translate_v3.TranslationServiceClient() + +# 3. Create Request +project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian") +parent = f"projects/{project_id}/locations/global" + +request = translation_service.TranslateTextRequest( + contents=["Hello, world!", "OpenTelemetry is braw!"], + target_language_code="es", + source_language_code="en", + model=f"{parent}/models/general/nmt", + mime_type="text/plain", + parent=parent, +) + +# 4. Call API +print("Sending translate request...") +try: + response = client.translate_text(request) + print("Translation Response received.") + print(f"Translated text: {response.translations[0].translated_text}") +except Exception as e: + print(f"API Call failed (expected if no real credentials): {e}") + +print("Done. Check console output for traces.") diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py deleted file mode 100644 index 44e952af366d..000000000000 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ /dev/null @@ -1,136 +0,0 @@ -# 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 pytest - -from google.api_core.observability import options -from google.api_core.observability.options import ( - _get_env_bool, - _strtobool, - clear_test_env_overrides, - set_test_env_override, -) - - -@pytest.fixture(autouse=True) -def clean_overrides(): - yield - clear_test_env_overrides() - - -@pytest.mark.parametrize( - "value,expected", - [ - ("y", True), - ("yes", True), - ("t", True), - ("true", True), - ("on", True), - ("1", True), - ("n", False), - ("no", False), - ("f", False), - ("false", False), - ("off", False), - ("0", False), - (" True ", True), - (" FALSE ", False), - ("", None), - ], -) -def test_strtobool(value, expected): - assert _strtobool(value) is expected - - -def test_strtobool_invalid(): - with pytest.raises(ValueError): - _strtobool("invalid") - - -def test_get_env_bool(monkeypatch): - monkeypatch.setenv("TEST_VAR", "true") - assert _get_env_bool("TEST_VAR") is True - - monkeypatch.setenv("TEST_VAR", "invalid") - assert _get_env_bool("TEST_VAR") is None - - monkeypatch.delenv("TEST_VAR", raising=False) - assert _get_env_bool("TEST_VAR") is None - - -@pytest.mark.parametrize( - "signal_type, env_vars, client_options, default_val, expected", - [ - # Default fallback tests - ("tracing", {}, None, False, False), - ("tracing", {}, None, True, True), - # Global env var - ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": True}, None, False, True), - ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, None, True, False), - # Experimental fallback - ( - "tracing", - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, - None, - False, - True, - ), - ( - "tracing", - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": False}, - None, - True, - False, - ), - # Implicit opt-in with provider - ( - "tracing", - {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, - {"tracer_provider": object()}, - False, - True, - ), - # Programmatic boolean flags are NOT supported (should default/fallback) - ( - "tracing", - {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, - {"enable_traces": True}, - False, - False, - ), - ( - "tracing", - {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, - {"enable_tracing": True}, - False, - False, - ), - ], -) -def test_is_signal_enabled( - signal_type, env_vars, client_options, default_val, expected -): - # Setup environment variables using our test overrides - for k, v in env_vars.items(): - set_test_env_override(k, v) - - result = options.is_signal_enabled( - signal_type, client_options=client_options, default=default_val - ) - assert result is expected - - -def test_is_signal_enabled_invalid_signal(): - with pytest.raises(ValueError, match="Only 'tracing' is supported"): - options.is_signal_enabled("traces") diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py index d1ba00802480..29eafbadbfe6 100644 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -12,149 +12,175 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Tests for OpenTelemetry Tracing Interceptors.""" + from unittest.mock import MagicMock, Mock import pytest -# Check if grpc is available try: - import grpc - - has_grpc = True + import grpc # noqa: F401 + from opentelemetry import trace except ImportError: - has_grpc = False - -# Skip all tests in this module if grpc is not installed -pytestmark = pytest.mark.skipif(not has_grpc, reason="grpc package is required") - -if has_grpc: + # TODO: add variables to highlight which dependency failed. + pytest.skip( + "Skipping gRPC/OTel tests because dependencies are missing", allow_hide_cpp=True + ) - class MockClientCallDetails(grpc.ClientCallDetails): - pass -else: - # Tell mypy that we are intentionally redefining this class for the non-gRPC fallback path. - class MockClientCallDetails: # type: ignore[no-redef] - pass +class MockClientCallDetails: + def __init__( + self, + method="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", + ): + self.method = method + self.timeout = None + self.metadata = [] + self.credentials = None + self.wait_for_ready = None @pytest.fixture -def mock_span(mocker): - """Mocks trace.get_current_span to return a recording span.""" +def mock_tracer(mocker): + """Mocks tracer and start_as_current_span context manager.""" + mock_tracer_obj = MagicMock() mock_span_obj = MagicMock() - mock_span_obj.is_recording.return_value = True - mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) - return mock_span_obj + # Configure start_as_current_span to act as a context manager returning mock_span_obj + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_span_obj + mock_tracer_obj.start_as_current_span.return_value = mock_cm -@pytest.fixture -def mock_span_non_recording(mocker): - """Mocks trace.get_current_span to return a non-recording span.""" - mock_span_obj = MagicMock() - mock_span_obj.is_recording.return_value = False - mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) - return mock_span_obj + mocker.patch("opentelemetry.trace.get_tracer", return_value=mock_tracer_obj) + return mock_tracer_obj, mock_span_obj -def test_enricher_non_recording_span(mock_span_non_recording): - """Verifies that non-recording spans do not have attributes set and extractor is skipped.""" - from google.api_core.observability.tracing import OtelSpanEnricher +def test_interceptor_creates_span(mock_tracer, monkeypatch): + """F1.7 (Partial): Verifies that the interceptor creates a CLIENT span with the correct name.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor - extractor = Mock() - enricher = OtelSpanEnricher( - static_attributes={"static.key": "static.val"}, attribute_extractor=extractor - ) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + + interceptor = OtelUnaryClientInterceptor() continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "request" + details = MockClientCallDetails(method="/MyService/MyMethod") + request = "request_payload" - res = enricher.intercept_unary_unary(continuation, details, request) + res = interceptor.intercept_unary_unary(continuation, details, request) assert res == "response" - continuation.assert_called_once_with(details, request) - mock_span_non_recording.set_attribute.assert_not_called() - extractor.assert_not_called() - - -@pytest.mark.parametrize( - "static_attrs,request_val,extractor_return,expected_attrs", - [ - # Case 1: Only static attributes - ({"static.key": "static.val"}, "req", None, {"static.key": "static.val"}), - # Case 2: Only dynamic attributes - (None, "req", {"dynamic.key": "dynamic.val"}, {"dynamic.key": "dynamic.val"}), - # Case 3: Both static and dynamic - ( - {"static.key": "static.val"}, - "req", - {"dynamic.key": "dynamic.val"}, - {"static.key": "static.val", "dynamic.key": "dynamic.val"}, - ), - # Case 4: Dynamic extractor returns None values (should be skipped) - ( - {"static.key": "static.val"}, - "req", - {"dynamic.key": None, "other.key": "other.val"}, - {"static.key": "static.val", "other.key": "other.val"}, - ), - ], -) -def test_enricher_recording_span( - mock_span, static_attrs, request_val, extractor_return, expected_attrs -): - """Verifies static and dynamic attribute resolution on recording spans.""" - from google.api_core.observability.tracing import OtelSpanEnricher - - if extractor_return is not None: - extractor = Mock(return_value=extractor_return) - else: - extractor = None - - enricher = OtelSpanEnricher( - static_attributes=static_attrs, attribute_extractor=extractor + + # Verify span creation + mock_tracer_obj.start_as_current_span.assert_called_once_with( + "/MyService/MyMethod", kind=trace.SpanKind.CLIENT ) + # Verify continuation was called + continuation.assert_called_once_with(details, request) + + +def test_interceptor_disabled(mock_tracer, monkeypatch): + """F1.6: Verifies that the interceptor does NOT create a span if disabled.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + mock_tracer_obj, _ = mock_tracer + + interceptor = OtelUnaryClientInterceptor() + continuation = Mock(return_value="response") details = MockClientCallDetails() - request = request_val + request = "request_payload" - res = enricher.intercept_unary_unary(continuation, details, request) + res = interceptor.intercept_unary_unary(continuation, details, request) assert res == "response" + + # Verify NO span creation + mock_tracer_obj.start_as_current_span.assert_not_called() + + # Verify continuation was called continuation.assert_called_once_with(details, request) - # Check that expected attributes were set - for key, val in expected_attrs.items(): - mock_span.set_attribute.assert_any_call(key, val) - # Total set_attribute calls should match expected_attrs size - assert mock_span.set_attribute.call_count == len(expected_attrs) +def test_interceptor_adds_static_attributes(mock_tracer, monkeypatch): + """Verifies that static attributes are added to the span.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - if extractor: - extractor.assert_called_once_with(request, details) + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + static_attrs = {"gcp.client.repo": "googleapis/google-cloud-python"} + interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) -def test_enricher_extractor_exception(mock_span): - """Verifies that exceptions in attribute extraction are caught and do not fail the call.""" - from google.api_core.observability.tracing import OtelSpanEnricher + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request_payload" - def bad_extractor(req, details): - raise ValueError("Extraction failure") + interceptor.intercept_unary_unary(continuation, details, request) - enricher = OtelSpanEnricher( - static_attributes={"static.key": "static.val"}, - attribute_extractor=bad_extractor, + # Verify attributes set + mock_span_obj.set_attribute.assert_any_call( + "gcp.client.repo", "googleapis/google-cloud-python" ) + mock_span_obj.set_attribute.assert_any_call("rpc.system.name", "grpc") + + +def test_interceptor_non_recording_span(mock_tracer, monkeypatch): + """Verifies that non-recording spans skip attribute injection.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = False + + static_attrs = {"static.key": "static.val"} + interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) continuation = Mock(return_value="response") details = MockClientCallDetails() - request = "req" + request = "request_payload" - res = enricher.intercept_unary_unary(continuation, details, request) + interceptor.intercept_unary_unary(continuation, details, request) - assert res == "response" - continuation.assert_called_once_with(details, request) + # Verify set_attribute was NOT called + mock_span_obj.set_attribute.assert_not_called() + + +def test_interceptor_extracts_destination_id(mock_tracer, monkeypatch): + """F1.7 (Partial): Verifies that the interceptor extracts gcp.resource.destination.id from metadata.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + + interceptor = OtelUnaryClientInterceptor() - # Static attributes should still be set before extractor failure - mock_span.set_attribute.assert_called_once_with("static.key", "static.val") + continuation = Mock(return_value="response") + details = MockClientCallDetails() + # Simulate standard routing metadata + details.metadata = [ + ( + "x-goog-request-params", + "name=projects/my-project/secrets/my-secret/versions/1&other=val", + ) + ] + request = "request_payload" + + interceptor.intercept_unary_unary(continuation, details, request) + + # Verify attribute was extracted and set + mock_span_obj.set_attribute.assert_any_call( + "gcp.resource.destination.id", + "projects/my-project/secrets/my-secret/versions/1", + ) From e443dfe5149a2126ba09cb66ff46031a613803e3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 11:05:30 -0400 Subject: [PATCH 06/16] chore(api-core): remove obsolete samples and fix pytest skip logic Removed Translate samples as we are shifting focus to Secret Manager and avoiding auto-instrumentation. Fixed a TypeError in pytest.skip usage in test_tracing.py. --- .../samples/sample_auto_translate_trace.py | 52 ------------------- .../samples/sample_translate_trace.py | 41 --------------- .../tests/unit/observability/test_tracing.py | 12 +++-- 3 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 packages/google-api-core/samples/sample_auto_translate_trace.py delete mode 100644 packages/google-api-core/samples/sample_translate_trace.py diff --git a/packages/google-api-core/samples/sample_auto_translate_trace.py b/packages/google-api-core/samples/sample_auto_translate_trace.py deleted file mode 100644 index ab560b243063..000000000000 --- a/packages/google-api-core/samples/sample_auto_translate_trace.py +++ /dev/null @@ -1,52 +0,0 @@ -import os - -from google.cloud import translate_v3 -from google.cloud.translate_v3.types import translation_service - -# 🚀 1. ACTIVATE MONKEY PATCHING (Auto-Instrumentation) -# This reaches into the gRPC library and wraps standard functions dynamically. -from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient - -GrpcInstrumentorClient().instrument() -print("✅ gRPC Client Auto-Instrumentation activated!") - -# 2. Standard OTel SDK Setup (Same as before, so we can see the console output) -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor - -print("Initializing TracerProvider...") -provider = TracerProvider() -exporter = ConsoleSpanExporter() -provider.add_span_processor(SimpleSpanProcessor(exporter)) -trace.set_tracer_provider(provider) -print("TracerProvider initialized.") - -# 3. Instantiate Client (Standard GAPIC, NO manual instrumentation used here) -print("Instantiating TranslationServiceClient...") -client = translate_v3.TranslationServiceClient() -print("TranslationServiceClient instantiated.") - -# 4. Create Request -project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian") -parent = f"projects/{project_id}/locations/global" - -request = translation_service.TranslateTextRequest( - contents=["Hello, world!", "OpenTelemetry is braw!"], - target_language_code="es", - source_language_code="en", - model=f"{parent}/models/general/nmt", - mime_type="text/plain", - parent=parent, -) - -# 5. Call API -print("Sending translate request...") -try: - response = client.translate_text(request) - print("Translation Response received.") - print(f"Translated text: {response.translations[0].translated_text}") -except Exception as e: - print(f"API Call failed: {e}") - -print("Done. Check console output for traces.") diff --git a/packages/google-api-core/samples/sample_translate_trace.py b/packages/google-api-core/samples/sample_translate_trace.py deleted file mode 100644 index e52bde13909e..000000000000 --- a/packages/google-api-core/samples/sample_translate_trace.py +++ /dev/null @@ -1,41 +0,0 @@ -import os - -from google.cloud import translate_v3 -from google.cloud.translate_v3.types import translation_service -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor - -# 1. Setup OTel with Console Exporter -provider = TracerProvider() -exporter = ConsoleSpanExporter() -provider.add_span_processor(SimpleSpanProcessor(exporter)) -trace.set_tracer_provider(provider) - -# 2. Instantiate Client -# Using standard Application Default Credentials (ADC). -client = translate_v3.TranslationServiceClient() - -# 3. Create Request -project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian") -parent = f"projects/{project_id}/locations/global" - -request = translation_service.TranslateTextRequest( - contents=["Hello, world!", "OpenTelemetry is braw!"], - target_language_code="es", - source_language_code="en", - model=f"{parent}/models/general/nmt", - mime_type="text/plain", - parent=parent, -) - -# 4. Call API -print("Sending translate request...") -try: - response = client.translate_text(request) - print("Translation Response received.") - print(f"Translated text: {response.translations[0].translated_text}") -except Exception as e: - print(f"API Call failed (expected if no real credentials): {e}") - -print("Done. Check console output for traces.") diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py index 29eafbadbfe6..51cc9f295489 100644 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -18,14 +18,16 @@ import pytest +has_deps = True try: import grpc # noqa: F401 - from opentelemetry import trace + from opentelemetry import trace # noqa: F401 except ImportError: - # TODO: add variables to highlight which dependency failed. - pytest.skip( - "Skipping gRPC/OTel tests because dependencies are missing", allow_hide_cpp=True - ) + has_deps = False + +pytestmark = pytest.mark.skipif( + not has_deps, reason="Skipping gRPC/OTel tests because dependencies are missing" +) class MockClientCallDetails: From 1073885283fa382bdb43e01a1070269095dc6920 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 11:13:46 -0400 Subject: [PATCH 07/16] test(api-core): achieve 100% coverage for observability package Added tests for __init__.py import failures and expanded metadata parsing tests in test_tracing.py to cover all branches and error handling. --- .../tests/unit/observability/test_init.py | 53 ++++++++++++++++ .../tests/unit/observability/test_tracing.py | 60 ++++++++++++++----- 2 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 packages/google-api-core/tests/unit/observability/test_init.py diff --git a/packages/google-api-core/tests/unit/observability/test_init.py b/packages/google-api-core/tests/unit/observability/test_init.py new file mode 100644 index 000000000000..f11022fae1ba --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_init.py @@ -0,0 +1,53 @@ +# 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 google.api_core.observability.__init__.""" + +import importlib +import sys + + +def test_init_exports(): + import google.api_core.observability + + # Check if dependencies are available + try: + import grpc # noqa: F401 + import opentelemetry.trace # noqa: F401 + + has_deps = True + except ImportError: + has_deps = False + + if has_deps: + assert "OtelUnaryClientInterceptor" in google.api_core.observability.__all__ + else: + assert google.api_core.observability.__all__ == [] + + +def test_init_import_error_forced(monkeypatch): + """Verifies behavior when tracing module fails to import, even if deps are present.""" + import google.api_core.observability + + # Poison the tracing module + monkeypatch.setitem(sys.modules, "google.api_core.observability.tracing", None) + + # Reload observability, it should fail to import tracing and trigger except block + importlib.reload(google.api_core.observability) + + assert google.api_core.observability.__all__ == [] + + # Clean up + monkeypatch.undo() + importlib.reload(google.api_core.observability) diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py index 51cc9f295489..fc21fe4fbb1f 100644 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -157,8 +157,41 @@ def test_interceptor_non_recording_span(mock_tracer, monkeypatch): mock_span_obj.set_attribute.assert_not_called() -def test_interceptor_extracts_destination_id(mock_tracer, monkeypatch): - """F1.7 (Partial): Verifies that the interceptor extracts gcp.resource.destination.id from metadata.""" +@pytest.mark.parametrize( + "metadata,expected_destination_id", + [ + # Case A: Success with 'name' + ( + [ + ( + "x-goog-request-params", + "name=projects/my-project/secrets/my-secret/versions/1&other=val", + ) + ], + "projects/my-project/secrets/my-secret/versions/1", + ), + # Case B: Success with 'parent' + ( + [ + ( + "x-goog-request-params", + "parent=projects/my-project/locations/us-central1&other=val", + ) + ], + "projects/my-project/locations/us-central1", + ), + # Case C: Other metadata keys (Loop continues, no destination id) + ([("some-other-header", "value")], None), + # Case D: x-goog-request-params exists but no name/parent + ([("x-goog-request-params", "other=val")], None), + # Case E: Malformed x-goog-request-params (Exception caught, fails open) + ([("x-goog-request-params", "name=foo=bar")], None), + ], +) +def test_interceptor_metadata_parsing( + mock_tracer, monkeypatch, metadata, expected_destination_id +): + """F1.7 (Partial): Verifies metadata parsing scenarios, including success, missing keys, and malformed data.""" from google.api_core.observability.tracing import OtelUnaryClientInterceptor monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") @@ -170,19 +203,18 @@ def test_interceptor_extracts_destination_id(mock_tracer, monkeypatch): continuation = Mock(return_value="response") details = MockClientCallDetails() - # Simulate standard routing metadata - details.metadata = [ - ( - "x-goog-request-params", - "name=projects/my-project/secrets/my-secret/versions/1&other=val", - ) - ] + details.metadata = metadata request = "request_payload" interceptor.intercept_unary_unary(continuation, details, request) - # Verify attribute was extracted and set - mock_span_obj.set_attribute.assert_any_call( - "gcp.resource.destination.id", - "projects/my-project/secrets/my-secret/versions/1", - ) + if expected_destination_id: + mock_span_obj.set_attribute.assert_any_call( + "gcp.resource.destination.id", expected_destination_id + ) + else: + # Verify gcp.resource.destination.id was NOT called + called_keys = [ + call[0][0] for call in mock_span_obj.set_attribute.call_args_list + ] + assert "gcp.resource.destination.id" not in called_keys From 01372b6920f6d42a9e6fbeae48a78a08bfbe7f9b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 09:20:40 -0400 Subject: [PATCH 08/16] chore(api-core): remove custom interceptor prototype --- .../google/api_core/observability/__init__.py | 10 +- .../google/api_core/observability/tracing.py | 96 -------- .../tests/unit/observability/test_init.py | 53 ----- .../tests/unit/observability/test_tracing.py | 220 ------------------ 4 files changed, 1 insertion(+), 378 deletions(-) delete mode 100644 packages/google-api-core/google/api_core/observability/tracing.py delete mode 100644 packages/google-api-core/tests/unit/observability/test_init.py delete mode 100644 packages/google-api-core/tests/unit/observability/test_tracing.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index aea71d26d164..a9a2c5b3bb43 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,9 +1 @@ -try: - # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. - from .tracing import OtelUnaryClientInterceptor # noqa: F401 - - __all__ = [ - "OtelUnaryClientInterceptor", - ] -except ImportError: - __all__ = [] +__all__ = [] diff --git a/packages/google-api-core/google/api_core/observability/tracing.py b/packages/google-api-core/google/api_core/observability/tracing.py deleted file mode 100644 index 64a01887ba10..000000000000 --- a/packages/google-api-core/google/api_core/observability/tracing.py +++ /dev/null @@ -1,96 +0,0 @@ -# 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 Tracing Enrichment Interceptors.""" - -from typing import Any, Callable, Dict, Optional - -import grpc -from opentelemetry import trace - - -class OtelUnaryClientInterceptor(grpc.UnaryUnaryClientInterceptor): - """A gRPC client interceptor that creates OpenTelemetry spans for outgoing requests. - - This interceptor explicitly creates a standard SpanKind.CLIENT span for each network attempt - and enriches it with standard Google Cloud attributes. - """ - - def __init__( - self, - static_attributes: Optional[Dict[str, Any]] = None, - ): - """Initializes the OtelUnaryClientInterceptor. - - Args: - static_attributes: Standard static attributes to attach to every span. - E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} - """ - self._static_attributes = static_attributes or {} - - def intercept_unary_unary( - self, - continuation: Callable[[grpc.ClientCallDetails, Any], Any], - client_call_details: grpc.ClientCallDetails, - request: Any, - ) -> Any: - from google.api_core._feature_gating_helpers import resolve_feature_flags - - # For now, we only check environment variables as we don't have access to ClientOptions here. - # To support programmatic configuration, we would need to pass it during Client - # initialization. - # TODO: we need to refactor resolve_feature_flags to allows feature_key to be optional. - enabled = resolve_feature_flags( - env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", - feature_key="tracer_provider", - ) - - if not enabled: - return continuation(client_call_details, request) - - tracer = trace.get_tracer(__name__) - - # Determine span name (e.g., from client_call_details.method) - span_name = client_call_details.method - - with tracer.start_as_current_span( - span_name, kind=trace.SpanKind.CLIENT - ) as span: - if span.is_recording(): - # Inject static attributes - for key, val in self._static_attributes.items(): - span.set_attribute(key, val) - - # Extract dynamic attributes from metadata - for key, value in client_call_details.metadata: - if key == "x-goog-request-params": - try: - # x-goog-request-params is urlencoded string of key=value pairs separated by & - params = dict( - p.split("=") for p in value.split("&") if "=" in p - ) - - # Standard resource identifiers are usually in 'name' or 'parent' - resource_id = params.get("name") or params.get("parent") - if resource_id: - span.set_attribute( - "gcp.resource.destination.id", resource_id - ) - except Exception: - # Fail open if parsing fails to avoid breaking the request - pass - - span.set_attribute("rpc.system.name", "grpc") - - return continuation(client_call_details, request) diff --git a/packages/google-api-core/tests/unit/observability/test_init.py b/packages/google-api-core/tests/unit/observability/test_init.py deleted file mode 100644 index f11022fae1ba..000000000000 --- a/packages/google-api-core/tests/unit/observability/test_init.py +++ /dev/null @@ -1,53 +0,0 @@ -# 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 google.api_core.observability.__init__.""" - -import importlib -import sys - - -def test_init_exports(): - import google.api_core.observability - - # Check if dependencies are available - try: - import grpc # noqa: F401 - import opentelemetry.trace # noqa: F401 - - has_deps = True - except ImportError: - has_deps = False - - if has_deps: - assert "OtelUnaryClientInterceptor" in google.api_core.observability.__all__ - else: - assert google.api_core.observability.__all__ == [] - - -def test_init_import_error_forced(monkeypatch): - """Verifies behavior when tracing module fails to import, even if deps are present.""" - import google.api_core.observability - - # Poison the tracing module - monkeypatch.setitem(sys.modules, "google.api_core.observability.tracing", None) - - # Reload observability, it should fail to import tracing and trigger except block - importlib.reload(google.api_core.observability) - - assert google.api_core.observability.__all__ == [] - - # Clean up - monkeypatch.undo() - importlib.reload(google.api_core.observability) diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py deleted file mode 100644 index fc21fe4fbb1f..000000000000 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ /dev/null @@ -1,220 +0,0 @@ -# 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 Tracing Interceptors.""" - -from unittest.mock import MagicMock, Mock - -import pytest - -has_deps = True -try: - import grpc # noqa: F401 - from opentelemetry import trace # noqa: F401 -except ImportError: - has_deps = False - -pytestmark = pytest.mark.skipif( - not has_deps, reason="Skipping gRPC/OTel tests because dependencies are missing" -) - - -class MockClientCallDetails: - def __init__( - self, - method="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", - ): - self.method = method - self.timeout = None - self.metadata = [] - self.credentials = None - self.wait_for_ready = None - - -@pytest.fixture -def mock_tracer(mocker): - """Mocks tracer and start_as_current_span context manager.""" - mock_tracer_obj = MagicMock() - mock_span_obj = MagicMock() - - # Configure start_as_current_span to act as a context manager returning mock_span_obj - mock_cm = MagicMock() - mock_cm.__enter__.return_value = mock_span_obj - mock_tracer_obj.start_as_current_span.return_value = mock_cm - - mocker.patch("opentelemetry.trace.get_tracer", return_value=mock_tracer_obj) - return mock_tracer_obj, mock_span_obj - - -def test_interceptor_creates_span(mock_tracer, monkeypatch): - """F1.7 (Partial): Verifies that the interceptor creates a CLIENT span with the correct name.""" - from google.api_core.observability.tracing import OtelUnaryClientInterceptor - - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - - mock_tracer_obj, mock_span_obj = mock_tracer - mock_span_obj.is_recording.return_value = True - - interceptor = OtelUnaryClientInterceptor() - - continuation = Mock(return_value="response") - details = MockClientCallDetails(method="/MyService/MyMethod") - request = "request_payload" - - res = interceptor.intercept_unary_unary(continuation, details, request) - - assert res == "response" - - # Verify span creation - mock_tracer_obj.start_as_current_span.assert_called_once_with( - "/MyService/MyMethod", kind=trace.SpanKind.CLIENT - ) - - # Verify continuation was called - continuation.assert_called_once_with(details, request) - - -def test_interceptor_disabled(mock_tracer, monkeypatch): - """F1.6: Verifies that the interceptor does NOT create a span if disabled.""" - from google.api_core.observability.tracing import OtelUnaryClientInterceptor - - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") - - mock_tracer_obj, _ = mock_tracer - - interceptor = OtelUnaryClientInterceptor() - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "request_payload" - - res = interceptor.intercept_unary_unary(continuation, details, request) - - assert res == "response" - - # Verify NO span creation - mock_tracer_obj.start_as_current_span.assert_not_called() - - # Verify continuation was called - continuation.assert_called_once_with(details, request) - - -def test_interceptor_adds_static_attributes(mock_tracer, monkeypatch): - """Verifies that static attributes are added to the span.""" - from google.api_core.observability.tracing import OtelUnaryClientInterceptor - - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - - mock_tracer_obj, mock_span_obj = mock_tracer - mock_span_obj.is_recording.return_value = True - - static_attrs = {"gcp.client.repo": "googleapis/google-cloud-python"} - interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "request_payload" - - interceptor.intercept_unary_unary(continuation, details, request) - - # Verify attributes set - mock_span_obj.set_attribute.assert_any_call( - "gcp.client.repo", "googleapis/google-cloud-python" - ) - mock_span_obj.set_attribute.assert_any_call("rpc.system.name", "grpc") - - -def test_interceptor_non_recording_span(mock_tracer, monkeypatch): - """Verifies that non-recording spans skip attribute injection.""" - from google.api_core.observability.tracing import OtelUnaryClientInterceptor - - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - - mock_tracer_obj, mock_span_obj = mock_tracer - mock_span_obj.is_recording.return_value = False - - static_attrs = {"static.key": "static.val"} - interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "request_payload" - - interceptor.intercept_unary_unary(continuation, details, request) - - # Verify set_attribute was NOT called - mock_span_obj.set_attribute.assert_not_called() - - -@pytest.mark.parametrize( - "metadata,expected_destination_id", - [ - # Case A: Success with 'name' - ( - [ - ( - "x-goog-request-params", - "name=projects/my-project/secrets/my-secret/versions/1&other=val", - ) - ], - "projects/my-project/secrets/my-secret/versions/1", - ), - # Case B: Success with 'parent' - ( - [ - ( - "x-goog-request-params", - "parent=projects/my-project/locations/us-central1&other=val", - ) - ], - "projects/my-project/locations/us-central1", - ), - # Case C: Other metadata keys (Loop continues, no destination id) - ([("some-other-header", "value")], None), - # Case D: x-goog-request-params exists but no name/parent - ([("x-goog-request-params", "other=val")], None), - # Case E: Malformed x-goog-request-params (Exception caught, fails open) - ([("x-goog-request-params", "name=foo=bar")], None), - ], -) -def test_interceptor_metadata_parsing( - mock_tracer, monkeypatch, metadata, expected_destination_id -): - """F1.7 (Partial): Verifies metadata parsing scenarios, including success, missing keys, and malformed data.""" - from google.api_core.observability.tracing import OtelUnaryClientInterceptor - - monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") - - mock_tracer_obj, mock_span_obj = mock_tracer - mock_span_obj.is_recording.return_value = True - - interceptor = OtelUnaryClientInterceptor() - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - details.metadata = metadata - request = "request_payload" - - interceptor.intercept_unary_unary(continuation, details, request) - - if expected_destination_id: - mock_span_obj.set_attribute.assert_any_call( - "gcp.resource.destination.id", expected_destination_id - ) - else: - # Verify gcp.resource.destination.id was NOT called - called_keys = [ - call[0][0] for call in mock_span_obj.set_attribute.call_args_list - ] - assert "gcp.resource.destination.id" not in called_keys From f68bc17335e7b835802094d92ae57b7eb9fe77d5 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 09:20:55 -0400 Subject: [PATCH 09/16] test(api-core): add failing tests for stock otel interceptor integration --- .../tests/unit/test_grpc_helpers_otel.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 packages/google-api-core/tests/unit/test_grpc_helpers_otel.py 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..7a3aa51f1660 --- /dev/null +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -0,0 +1,118 @@ +# 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 +from unittest import mock + +import grpc +import pytest +from google.api_core import grpc_helpers + + +@pytest.fixture +def clean_sys_modules(): + """Fixture to ensure opentelemetry modules are unloaded before and after tests.""" + modules_to_remove = [ + "opentelemetry.instrumentation.grpc", + "opentelemetry.instrumentation", + "opentelemetry", + ] + for mod in modules_to_remove: + if mod in sys.modules: + del sys.modules[mod] + yield + for mod in modules_to_remove: + if mod in sys.modules: + del sys.modules[mod] + + +def test_create_channel_otel_installed_and_enabled(monkeypatch, clean_sys_modules): + """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" + + # Mock opentelemetry.instrumentation.grpc + mock_otel_grpc = mock.Mock() + mock_interceptor = mock.Mock() + mock_otel_grpc.client_interceptor.return_value = mock_interceptor + mock_otel_grpc.intercept_channel.side_effect = lambda ch, inc: f"wrapped_{ch}" + + sys.modules["opentelemetry.instrumentation.grpc"] = mock_otel_grpc + + # Enable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + # Mock grpc.secure_channel + mock_channel = "raw_channel" + with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: + # We need to mock credentials setup to avoid external calls + with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify OTel interceptor was fetched and channel was wrapped + mock_otel_grpc.client_interceptor.assert_called_once() + mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) + + # Verify returned channel is the wrapped one + assert channel == f"wrapped_{mock_channel}" + + +def test_create_channel_otel_installed_but_disabled(monkeypatch, clean_sys_modules): + """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" + + mock_otel_grpc = mock.Mock() + sys.modules["opentelemetry.instrumentation.grpc"] = mock_otel_grpc + + # Disable tracing (or leave unset, default should be false/disabled) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + mock_channel = "raw_channel" + with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: + with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify OTel was NOT used + mock_otel_grpc.intercept_channel.assert_not_called() + + # Verify returned channel is the raw one + assert channel == mock_channel + + +def test_create_channel_otel_not_installed_fails_open(monkeypatch, clean_sys_modules): + """Verify that create_channel fails open if OTel is not installed, even if enabled.""" + + # Ensure it's not in sys.modules + if "opentelemetry.instrumentation.grpc" in sys.modules: + del sys.modules["opentelemetry.instrumentation.grpc"] + + # Enable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_channel = "raw_channel" + with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: + with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + # This should NOT raise ImportError + channel = grpc_helpers.create_channel("localhost:1234") + + # Verify raw channel was created + mock_secure_channel.assert_called_once() + + # Verify returned channel is the raw one + assert channel == mock_channel From 4698da97446091c643b5366ea74d004a05be17d1 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 10:29:48 -0400 Subject: [PATCH 10/16] feat(otel): implement E2E OpenTelemetry prototype for gRPC and Secret Manager Includes:\n- Soft dependency gRPC interceptor wiring in google-api-core\n- Custom T3 span enrichment in Secret Manager client\n- Unit tests verifying behavior --- .../google/api_core/grpc_helpers.py | 23 +++- .../tests/unit/test_grpc_helpers_otel.py | 8 ++ .../services/secret_manager_service/client.py | 39 ++++-- .../tests/unit/test_observability.py | 119 ++++++++++++++++++ 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 packages/google-cloud-secret-manager/tests/unit/test_observability.py 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..c3992f6e9386 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,28 @@ def create_channel( if attempt_direct_path: target = _modify_target_for_direct_path(target) - return grpc.secure_channel( + 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=None, + ) + + if is_tracing_enabled: + try: + import opentelemetry.instrumentation.grpc as otel_grpc + + interceptor = otel_grpc.client_interceptor() + channel = otel_grpc.intercept_channel(channel, interceptor) + except ImportError: + # Soft dependency missing, fail open + pass + + return channel + def _modify_target_for_direct_path(target: str) -> str: """ 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 index 7a3aa51f1660..170f95c2a6fe 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -43,11 +43,19 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch, clean_sys_module """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" # Mock opentelemetry.instrumentation.grpc + mock_otel = mock.Mock() + mock_otel_instrumentation = mock.Mock() mock_otel_grpc = mock.Mock() mock_interceptor = mock.Mock() mock_otel_grpc.client_interceptor.return_value = mock_interceptor mock_otel_grpc.intercept_channel.side_effect = lambda ch, inc: f"wrapped_{ch}" + # Link them + mock_otel.instrumentation = mock_otel_instrumentation + mock_otel_instrumentation.grpc = mock_otel_grpc + + sys.modules["opentelemetry"] = mock_otel + sys.modules["opentelemetry.instrumentation"] = mock_otel_instrumentation sys.modules["opentelemetry.instrumentation.grpc"] = mock_otel_grpc # Enable tracing diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index ff26bddcc57d..24640a37b3b7 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,17 +35,17 @@ ) import google.protobuf +from google.api_core import _feature_gating_helpers, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions -from google.api_core import gapic_v1 from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.exceptions import MutualTLSChannelError # type: ignore from google.auth.transport import mtls # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore -from google.oauth2 import service_account # type: ignore - from google.cloud.secretmanager_v1 import gapic_version as package_version +from google.oauth2 import service_account # type: ignore +from opentelemetry import trace try: OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None] @@ -59,6 +59,8 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +tracer = trace.get_tracer(__name__) + _LOGGER = std_logging.getLogger(__name__) import google.iam.v1.iam_policy_pb2 as iam_policy_pb2 # type: ignore @@ -68,7 +70,6 @@ import google.protobuf.field_mask_pb2 as field_mask_pb2 # type: ignore import google.protobuf.timestamp_pb2 as timestamp_pb2 # type: ignore from google.cloud.location import locations_pb2 # type: ignore - from google.cloud.secretmanager_v1.services.secret_manager_service import pagers from google.cloud.secretmanager_v1.types import resources, service @@ -1871,14 +1872,32 @@ def sample_access_secret_version(): # Validate the universe domain. self._validate_universe_domain() - # Send the request. - response = rpc( - request, - retry=retry, - timeout=timeout, - metadata=metadata, + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=self._client_options, ) + # Send the request. + if is_tracing_enabled: + with tracer.start_as_current_span("SecretManagerServiceClient.access_secret_version") as span: + span.set_attribute("gcp.secretmanager.secret.name", request.name) + response = rpc( + request, + retry=retry, + timeout=timeout, + metadata=metadata, + ) + else: + response = rpc( + request, + retry=retry, + timeout=timeout, + metadata=metadata, + ) + + return response + # Done; return the response. return response diff --git a/packages/google-cloud-secret-manager/tests/unit/test_observability.py b/packages/google-cloud-secret-manager/tests/unit/test_observability.py new file mode 100644 index 000000000000..dd13246ade39 --- /dev/null +++ b/packages/google-cloud-secret-manager/tests/unit/test_observability.py @@ -0,0 +1,119 @@ +# 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 importlib +from unittest import mock + +import pytest +from google.auth import credentials as ga_credentials +from google.cloud import secretmanager_v1 +from google.cloud.secretmanager_v1.types import service + +# We use the clean pattern from BigQuery tests +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture +def setup_otel(): + """Fixture to set up in-memory OTel exporting.""" + tracer_provider = TracerProvider() + memory_exporter = InMemorySpanExporter() + span_processor = SimpleSpanProcessor(memory_exporter) + tracer_provider.add_span_processor(span_processor) + + # Override internal global var to inject our provider + orig_trace_provider = trace._TRACER_PROVIDER + trace._TRACER_PROVIDER = tracer_provider + + yield memory_exporter + + trace._TRACER_PROVIDER = orig_trace_provider + + +def test_access_secret_version_custom_span(setup_otel, monkeypatch): + """Verify that calling access_secret_version produces a custom T3 span with attributes.""" + + # Enable tracing via env var (assuming this is how we gate it for clients too) + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + client = secretmanager_v1.SecretManagerServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + ) + + request = service.AccessSecretVersionRequest( + name="projects/test-project/secrets/test-secret/versions/1" + ) + + # Mock the actual transport call to avoid network calls and focus on wrapping + with mock.patch.object( + type(client.transport.access_secret_version), "__call__" + ) as call: + call.return_value = service.AccessSecretVersionResponse( + name="projects/test-project/secrets/test-secret/versions/1", + ) + + client.access_secret_version(request) + + # Verify spans + exported_spans = setup_otel.get_finished_spans() + + # We expect at least one span (the T3 span) + assert len(exported_spans) >= 1 + + # Find the T3 span (it should be the custom one from the client) + # Naming convention might be "SecretManagerServiceClient.access_secret_version" + t3_span = None + for span in exported_spans: + if "access_secret_version" in span.name: + t3_span = span + break + + assert t3_span is not None, "T3 span not found" + + # Verify custom attributes + attributes = t3_span.attributes + assert attributes.get("gcp.secretmanager.secret.name") == "projects/test-project/secrets/test-secret/versions/1" + + +def test_access_secret_version_custom_span_disabled(setup_otel, monkeypatch): + """Verify that calling access_secret_version does NOT produce custom span if disabled.""" + + # Disable tracing + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + client = secretmanager_v1.SecretManagerServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + ) + + request = service.AccessSecretVersionRequest( + name="projects/test-project/secrets/test-secret/versions/1" + ) + + with mock.patch.object( + type(client.transport.access_secret_version), "__call__" + ) as call: + call.return_value = service.AccessSecretVersionResponse( + name="projects/test-project/secrets/test-secret/versions/1", + ) + + client.access_secret_version(request) + + exported_spans = setup_otel.get_finished_spans() + + # We expect NO spans + assert len(exported_spans) == 0 + # We might also expect standard attributes like service.name etc, but let's focus on custom ones. From 63dd99870052a94990d8df39c76b98fd33e8efa4 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 11:31:26 -0400 Subject: [PATCH 11/16] feat(otel): handle soft dependencies and fix tests for older runtimes --- .../google/api_core/grpc_helpers.py | 21 ++++-- .../tests/asyncio/test_grpc_helpers_async.py | 16 +++- .../tests/unit/test_grpc_helpers.py | 18 +++-- .../tests/unit/test_grpc_helpers_otel.py | 73 +++++++++++-------- .../services/secret_manager_service/client.py | 41 ++++++++--- .../google-cloud-secret-manager/noxfile.py | 1 + packages/google-cloud-secret-manager/setup.py | 1 + .../tests/unit/test_observability.py | 18 ++++- 8 files changed, 131 insertions(+), 58 deletions(-) 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 c3992f6e9386..88817417ae4b 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -25,7 +25,13 @@ import google.auth.transport.requests import google.protobuf import grpc -from google.api_core import _feature_gating_helpers, exceptions, general_helpers +from google.api_core import exceptions, general_helpers + +try: + from google.api_core import _feature_gating_helpers + HAVE_FEATURE_GATING = True +except ImportError: + HAVE_FEATURE_GATING = False # The list of gRPC Callable interfaces that return iterators. _STREAM_WRAP_CLASSES = (grpc.UnaryStreamMultiCallable, grpc.StreamStreamMultiCallable) @@ -387,11 +393,14 @@ def create_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=None, - ) + if HAVE_FEATURE_GATING: + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=None, + ) + else: + is_tracing_enabled = False if is_tracing_enabled: try: 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 0855e15e9ec7..2f9bdc72d394 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 @@ -421,9 +420,18 @@ def test_create_channel_implicit_with_default_host( assert channel is grpc_secure_channel.return_value google_auth_default.assert_called_once_with(scopes=None, default_scopes=None) - auth_metadata_plugin.assert_called_once_with( - mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host - ) + + try: + auth_metadata_plugin.assert_called_once_with( + mock.sentinel.credentials, + mock.sentinel.Request, + default_host=default_host, + suppress_metrics_header=True, + ) + except AssertionError: + auth_metadata_plugin.assert_called_once_with( + mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host + ) grpc_secure_channel.assert_called_once_with( expected_target, composite_creds, compression=None ) 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 4f2912f82367..aba0a98d0da2 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(): @@ -453,9 +452,18 @@ def test_create_channel_implicit_with_default_host( assert channel is grpc_secure_channel.return_value google_auth_default.assert_called_once_with(scopes=None, default_scopes=None) - auth_metadata_plugin.assert_called_once_with( - mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host - ) + + try: + auth_metadata_plugin.assert_called_once_with( + mock.sentinel.credentials, + mock.sentinel.Request, + default_host=default_host, + suppress_metrics_header=True, + ) + except AssertionError: + auth_metadata_plugin.assert_called_once_with( + mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host + ) grpc_secure_channel.assert_called_once_with( expected_target, composite_creds, compression=None 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 index 170f95c2a6fe..f87c17f9a859 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -17,29 +17,21 @@ import sys from unittest import mock -import grpc import pytest -from google.api_core import grpc_helpers - - -@pytest.fixture -def clean_sys_modules(): - """Fixture to ensure opentelemetry modules are unloaded before and after tests.""" - modules_to_remove = [ - "opentelemetry.instrumentation.grpc", - "opentelemetry.instrumentation", - "opentelemetry", - ] - for mod in modules_to_remove: - if mod in sys.modules: - del sys.modules[mod] - yield - for mod in modules_to_remove: - if mod in sys.modules: - del sys.modules[mod] - - -def test_create_channel_otel_installed_and_enabled(monkeypatch, clean_sys_modules): + +try: + from google.api_core import grpc_helpers + HAVE_GRPC = True +except ImportError: + HAVE_GRPC = False + + +# Removed clean_sys_modules fixture as it causes issues in no-grpc environments +# when tests are collected. + + +@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +def test_create_channel_otel_installed_and_enabled(monkeypatch): """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" # Mock opentelemetry.instrumentation.grpc @@ -63,9 +55,14 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch, clean_sys_module # Mock grpc.secure_channel mock_channel = "raw_channel" - with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: # We need to mock credentials setup to avoid external calls - with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): channel = grpc_helpers.create_channel("localhost:1234") # Verify raw channel was created @@ -73,13 +70,16 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch, clean_sys_module # Verify OTel interceptor was fetched and channel was wrapped mock_otel_grpc.client_interceptor.assert_called_once() - mock_otel_grpc.intercept_channel.assert_called_once_with(mock_channel, mock_interceptor) + mock_otel_grpc.intercept_channel.assert_called_once_with( + mock_channel, mock_interceptor + ) # Verify returned channel is the wrapped one assert channel == f"wrapped_{mock_channel}" -def test_create_channel_otel_installed_but_disabled(monkeypatch, clean_sys_modules): +@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +def test_create_channel_otel_installed_but_disabled(monkeypatch): """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" mock_otel_grpc = mock.Mock() @@ -89,8 +89,13 @@ def test_create_channel_otel_installed_but_disabled(monkeypatch, clean_sys_modul monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") mock_channel = "raw_channel" - with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: - with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): channel = grpc_helpers.create_channel("localhost:1234") # Verify raw channel was created @@ -103,7 +108,8 @@ def test_create_channel_otel_installed_but_disabled(monkeypatch, clean_sys_modul assert channel == mock_channel -def test_create_channel_otel_not_installed_fails_open(monkeypatch, clean_sys_modules): +@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +def test_create_channel_otel_not_installed_fails_open(monkeypatch): """Verify that create_channel fails open if OTel is not installed, even if enabled.""" # Ensure it's not in sys.modules @@ -114,8 +120,13 @@ def test_create_channel_otel_not_installed_fails_open(monkeypatch, clean_sys_mod monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") mock_channel = "raw_channel" - with mock.patch("grpc.secure_channel", return_value=mock_channel) as mock_secure_channel: - with mock.patch("google.api_core.grpc_helpers._create_composite_credentials", return_value=mock.Mock()): + with mock.patch( + "grpc.secure_channel", return_value=mock_channel + ) as mock_secure_channel: + with mock.patch( + "google.api_core.grpc_helpers._create_composite_credentials", + return_value=mock.Mock(), + ): # This should NOT raise ImportError channel = grpc_helpers.create_channel("localhost:1234") diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 24640a37b3b7..697dd980282a 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -35,17 +35,30 @@ ) import google.protobuf -from google.api_core import _feature_gating_helpers, gapic_v1 from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions +from google.api_core import gapic_v1 from google.api_core import retry as retries + +try: + from google.api_core import _feature_gating_helpers + + HAVE_FEATURE_GATING = True +except ImportError: + HAVE_FEATURE_GATING = False from google.auth import credentials as ga_credentials # type: ignore from google.auth.exceptions import MutualTLSChannelError # type: ignore from google.auth.transport import mtls # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.cloud.secretmanager_v1 import gapic_version as package_version from google.oauth2 import service_account # type: ignore -from opentelemetry import trace + +try: + from opentelemetry import trace + + HAVE_OTEL = True +except ImportError: + HAVE_OTEL = False try: OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None] @@ -59,7 +72,10 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False -tracer = trace.get_tracer(__name__) +if HAVE_OTEL: + tracer = trace.get_tracer(__name__) +else: + tracer = None _LOGGER = std_logging.getLogger(__name__) @@ -1872,15 +1888,20 @@ def sample_access_secret_version(): # Validate the universe domain. self._validate_universe_domain() - is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( - env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", - feature_key="tracer_provider", - configuration=self._client_options, - ) + if HAVE_FEATURE_GATING: + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=self._client_options, + ) + else: + is_tracing_enabled = False # Send the request. - if is_tracing_enabled: - with tracer.start_as_current_span("SecretManagerServiceClient.access_secret_version") as span: + if is_tracing_enabled and HAVE_OTEL and tracer: + with tracer.start_as_current_span( + "SecretManagerServiceClient.access_secret_version" + ) as span: span.set_attribute("gcp.secretmanager.secret.name", request.name) response = rpc( request, diff --git a/packages/google-cloud-secret-manager/noxfile.py b/packages/google-cloud-secret-manager/noxfile.py index 3943f9aea974..af8314380aac 100644 --- a/packages/google-cloud-secret-manager/noxfile.py +++ b/packages/google-cloud-secret-manager/noxfile.py @@ -69,6 +69,7 @@ "pytest", "pytest-cov", "pytest-asyncio", + "opentelemetry-sdk", ] UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = [] UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [] diff --git a/packages/google-cloud-secret-manager/setup.py b/packages/google-cloud-secret-manager/setup.py index 69abc90c64cb..7bb523af5ce1 100644 --- a/packages/google-cloud-secret-manager/setup.py +++ b/packages/google-cloud-secret-manager/setup.py @@ -53,6 +53,7 @@ "proto-plus >= 1.26.1, <2.0.0", "protobuf >= 6.33.5, < 8.0.0", "grpc-google-iam-v1 >= 0.14.2, <1.0.0", + "opentelemetry-api >= 1.27.0, < 2.0.0", ] extras = {} url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-secret-manager" diff --git a/packages/google-cloud-secret-manager/tests/unit/test_observability.py b/packages/google-cloud-secret-manager/tests/unit/test_observability.py index dd13246ade39..c8f84e1f1690 100644 --- a/packages/google-cloud-secret-manager/tests/unit/test_observability.py +++ b/packages/google-cloud-secret-manager/tests/unit/test_observability.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import importlib from unittest import mock import pytest from google.auth import credentials as ga_credentials from google.cloud import secretmanager_v1 +from google.cloud.secretmanager_v1.services.secret_manager_service.client import ( + HAVE_FEATURE_GATING, + HAVE_OTEL, +) from google.cloud.secretmanager_v1.types import service # We use the clean pattern from BigQuery tests @@ -44,6 +47,10 @@ def setup_otel(): trace._TRACER_PROVIDER = orig_trace_provider +@pytest.mark.skipif( + not HAVE_FEATURE_GATING or not HAVE_OTEL, + reason="Requires feature gating and OpenTelemetry", +) def test_access_secret_version_custom_span(setup_otel, monkeypatch): """Verify that calling access_secret_version produces a custom T3 span with attributes.""" @@ -86,9 +93,16 @@ def test_access_secret_version_custom_span(setup_otel, monkeypatch): # Verify custom attributes attributes = t3_span.attributes - assert attributes.get("gcp.secretmanager.secret.name") == "projects/test-project/secrets/test-secret/versions/1" + assert ( + attributes.get("gcp.secretmanager.secret.name") + == "projects/test-project/secrets/test-secret/versions/1" + ) +@pytest.mark.skipif( + not HAVE_FEATURE_GATING or not HAVE_OTEL, + reason="Requires feature gating and OpenTelemetry", +) def test_access_secret_version_custom_span_disabled(setup_otel, monkeypatch): """Verify that calling access_secret_version does NOT produce custom span if disabled.""" From 6af4be80aa8294b4b5c0df5ec9fc5fc8093661a3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 11:50:41 -0400 Subject: [PATCH 12/16] fix(otel): resolve mypy errors and remove redundant checks --- .../google/api_core/grpc_helpers.py | 23 ++++++------------- .../tests/unit/test_grpc_helpers_otel.py | 6 ++--- .../services/secret_manager_service/client.py | 2 +- 3 files changed, 11 insertions(+), 20 deletions(-) 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 88817417ae4b..60455c6434cb 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -25,13 +25,7 @@ import google.auth.transport.requests import google.protobuf import grpc -from google.api_core import exceptions, general_helpers - -try: - from google.api_core import _feature_gating_helpers - HAVE_FEATURE_GATING = True -except ImportError: - HAVE_FEATURE_GATING = False +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) @@ -393,18 +387,15 @@ def create_channel( target, composite_credentials, compression=compression, **kwargs ) - if HAVE_FEATURE_GATING: - is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( - env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", - feature_key="tracer_provider", - configuration=None, - ) - else: - is_tracing_enabled = False + is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + configuration=None, + ) if is_tracing_enabled: try: - import opentelemetry.instrumentation.grpc as otel_grpc + import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] interceptor = otel_grpc.client_interceptor() channel = otel_grpc.intercept_channel(channel, interceptor) 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 index f87c17f9a859..739a4b1ac085 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -21,6 +21,7 @@ try: from google.api_core import grpc_helpers + HAVE_GRPC = True except ImportError: HAVE_GRPC = False @@ -112,9 +113,8 @@ def test_create_channel_otel_installed_but_disabled(monkeypatch): def test_create_channel_otel_not_installed_fails_open(monkeypatch): """Verify that create_channel fails open if OTel is not installed, even if enabled.""" - # Ensure it's not in sys.modules - if "opentelemetry.instrumentation.grpc" in sys.modules: - del sys.modules["opentelemetry.instrumentation.grpc"] + # Simulate missing module + monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None) # Enable tracing monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index 697dd980282a..a72de894948e 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -75,7 +75,7 @@ if HAVE_OTEL: tracer = trace.get_tracer(__name__) else: - tracer = None + tracer = None # type: ignore[assignment] _LOGGER = std_logging.getLogger(__name__) From 6b5c41edbcce7e91ec3db187ce82131453686c54 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 11:54:24 -0400 Subject: [PATCH 13/16] chore(api-core): remove empty observability module --- .../google-api-core/google/api_core/observability/__init__.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/google-api-core/google/api_core/observability/__init__.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py deleted file mode 100644 index a9a2c5b3bb43..000000000000 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__all__ = [] From ca058d8ea2841e7fbb7d7bff2045917505493b82 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 13:21:15 -0400 Subject: [PATCH 14/16] chore(otel): clarify test skip conditions and variable names --- .../tests/unit/test_grpc_helpers_otel.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 index 739a4b1ac085..c076ad3bfff8 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -22,16 +22,16 @@ try: from google.api_core import grpc_helpers - HAVE_GRPC = True + HAVE_GRPC_HELPERS = True except ImportError: - HAVE_GRPC = False + HAVE_GRPC_HELPERS = False # Removed clean_sys_modules fixture as it causes issues in no-grpc environments # when tests are collected. -@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_installed_and_enabled(monkeypatch): """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" @@ -46,10 +46,13 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch): # Link them mock_otel.instrumentation = mock_otel_instrumentation mock_otel_instrumentation.grpc = mock_otel_grpc - - sys.modules["opentelemetry"] = mock_otel - sys.modules["opentelemetry.instrumentation"] = mock_otel_instrumentation - sys.modules["opentelemetry.instrumentation.grpc"] = mock_otel_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 + ) # Enable tracing monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") @@ -79,12 +82,14 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch): assert channel == f"wrapped_{mock_channel}" -@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_installed_but_disabled(monkeypatch): """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" mock_otel_grpc = mock.Mock() - sys.modules["opentelemetry.instrumentation.grpc"] = mock_otel_grpc + monkeypatch.setitem( + sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc + ) # Disable tracing (or leave unset, default should be false/disabled) monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") @@ -109,7 +114,7 @@ def test_create_channel_otel_installed_but_disabled(monkeypatch): assert channel == mock_channel -@pytest.mark.skipif(not HAVE_GRPC, reason="Requires gRPC") +@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_not_installed_fails_open(monkeypatch): """Verify that create_channel fails open if OTel is not installed, even if enabled.""" From b5afe987ba89f778cd9cc9e36c83da8084a446b8 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 13:49:34 -0400 Subject: [PATCH 15/16] refactor(otel): rename HAVE_* to HAS_* and add explanatory comments --- .../google/api_core/grpc_helpers.py | 3 +-- .../tests/asyncio/test_grpc_helpers_async.py | 2 ++ .../tests/unit/test_grpc_helpers.py | 2 ++ .../tests/unit/test_grpc_helpers_otel.py | 18 +++++++++++------- .../services/secret_manager_service/client.py | 16 +++++++--------- .../tests/unit/test_observability.py | 8 ++++---- 6 files changed, 27 insertions(+), 22 deletions(-) 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 60455c6434cb..af211cf7866f 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers.py +++ b/packages/google-api-core/google/api_core/grpc_helpers.py @@ -396,11 +396,10 @@ def create_channel( if is_tracing_enabled: try: import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - interceptor = otel_grpc.client_interceptor() channel = otel_grpc.intercept_channel(channel, interceptor) except ImportError: - # Soft dependency missing, fail open + # If grpc dependency is missing, this should simply NOOP and fail open rather than failing import. pass return channel 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 2f9bdc72d394..2b65b9077bab 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 @@ -421,6 +421,8 @@ def test_create_channel_implicit_with_default_host( google_auth_default.assert_called_once_with(scopes=None, default_scopes=None) + # Suppressing metrics header prevents duplicate x-goog-api-client headers. + # We try both assertions to support older versions of google-auth. try: auth_metadata_plugin.assert_called_once_with( mock.sentinel.credentials, 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 aba0a98d0da2..40b5e4db8fb3 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers.py @@ -453,6 +453,8 @@ def test_create_channel_implicit_with_default_host( google_auth_default.assert_called_once_with(scopes=None, default_scopes=None) + # Suppressing metrics header prevents duplicate x-goog-api-client headers. + # We try both assertions to support older versions of google-auth. try: auth_metadata_plugin.assert_called_once_with( mock.sentinel.credentials, 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 index c076ad3bfff8..d8633b849e28 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -22,20 +22,21 @@ try: from google.api_core import grpc_helpers - HAVE_GRPC_HELPERS = True + HAS_GRPC_HELPERS = True except ImportError: - HAVE_GRPC_HELPERS = False + HAS_GRPC_HELPERS = False # Removed clean_sys_modules fixture as it causes issues in no-grpc environments # when tests are collected. -@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_installed_and_enabled(monkeypatch): """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" - # Mock opentelemetry.instrumentation.grpc + # Build a hierarchy of mocks to simulate the nested OpenTelemetry modules. + # This allows us to test code that imports these modules without needing them installed. mock_otel = mock.Mock() mock_otel_instrumentation = mock.Mock() mock_otel_grpc = mock.Mock() @@ -43,9 +44,12 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch): mock_otel_grpc.client_interceptor.return_value = mock_interceptor mock_otel_grpc.intercept_channel.side_effect = lambda ch, inc: f"wrapped_{ch}" - # Link them + # Link the mocks together to match the package structure (opentelemetry.instrumentation.grpc) mock_otel.instrumentation = mock_otel_instrumentation mock_otel_instrumentation.grpc = mock_otel_grpc + + # Inject the mocks into sys.modules so Python's import system uses them. + # monkeypatch ensures these changes are reverted after the test finishes. monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) monkeypatch.setitem( sys.modules, "opentelemetry.instrumentation", mock_otel_instrumentation @@ -82,7 +86,7 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch): assert channel == f"wrapped_{mock_channel}" -@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_installed_but_disabled(monkeypatch): """Verify that create_channel does NOT wrap the channel if tracing is disabled.""" @@ -114,7 +118,7 @@ def test_create_channel_otel_installed_but_disabled(monkeypatch): assert channel == mock_channel -@pytest.mark.skipif(not HAVE_GRPC_HELPERS, reason="Requires google-api-core[grpc]") +@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]") def test_create_channel_otel_not_installed_fails_open(monkeypatch): """Verify that create_channel fails open if OTel is not installed, even if enabled.""" diff --git a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py index a72de894948e..4db5aa8d9789 100644 --- a/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py +++ b/packages/google-cloud-secret-manager/google/cloud/secretmanager_v1/services/secret_manager_service/client.py @@ -42,10 +42,9 @@ try: from google.api_core import _feature_gating_helpers - - HAVE_FEATURE_GATING = True + HAS_FEATURE_GATING = True except ImportError: - HAVE_FEATURE_GATING = False + HAS_FEATURE_GATING = False from google.auth import credentials as ga_credentials # type: ignore from google.auth.exceptions import MutualTLSChannelError # type: ignore from google.auth.transport import mtls # type: ignore @@ -55,10 +54,9 @@ try: from opentelemetry import trace - - HAVE_OTEL = True + HAS_OTEL = True except ImportError: - HAVE_OTEL = False + HAS_OTEL = False try: OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None] @@ -72,7 +70,7 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False -if HAVE_OTEL: +if HAS_OTEL: tracer = trace.get_tracer(__name__) else: tracer = None # type: ignore[assignment] @@ -1888,7 +1886,7 @@ def sample_access_secret_version(): # Validate the universe domain. self._validate_universe_domain() - if HAVE_FEATURE_GATING: + if HAS_FEATURE_GATING: is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags( env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", feature_key="tracer_provider", @@ -1898,7 +1896,7 @@ def sample_access_secret_version(): is_tracing_enabled = False # Send the request. - if is_tracing_enabled and HAVE_OTEL and tracer: + if is_tracing_enabled and HAS_OTEL and tracer: with tracer.start_as_current_span( "SecretManagerServiceClient.access_secret_version" ) as span: diff --git a/packages/google-cloud-secret-manager/tests/unit/test_observability.py b/packages/google-cloud-secret-manager/tests/unit/test_observability.py index c8f84e1f1690..04a951e6243f 100644 --- a/packages/google-cloud-secret-manager/tests/unit/test_observability.py +++ b/packages/google-cloud-secret-manager/tests/unit/test_observability.py @@ -18,8 +18,8 @@ from google.auth import credentials as ga_credentials from google.cloud import secretmanager_v1 from google.cloud.secretmanager_v1.services.secret_manager_service.client import ( - HAVE_FEATURE_GATING, - HAVE_OTEL, + HAS_FEATURE_GATING, + HAS_OTEL, ) from google.cloud.secretmanager_v1.types import service @@ -48,7 +48,7 @@ def setup_otel(): @pytest.mark.skipif( - not HAVE_FEATURE_GATING or not HAVE_OTEL, + not HAS_FEATURE_GATING or not HAS_OTEL, reason="Requires feature gating and OpenTelemetry", ) def test_access_secret_version_custom_span(setup_otel, monkeypatch): @@ -100,7 +100,7 @@ def test_access_secret_version_custom_span(setup_otel, monkeypatch): @pytest.mark.skipif( - not HAVE_FEATURE_GATING or not HAVE_OTEL, + not HAS_FEATURE_GATING or not HAS_OTEL, reason="Requires feature gating and OpenTelemetry", ) def test_access_secret_version_custom_span_disabled(setup_otel, monkeypatch): From e0edc2cbce17bcc60797c4b3d7f6aed041bd316c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 7 Aug 2026 15:03:28 -0400 Subject: [PATCH 16/16] refactor(otel): simplify mock setup in tests using automatic nested mocks --- .../tests/unit/test_grpc_helpers_otel.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 index d8633b849e28..399d4924e3a9 100644 --- a/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py +++ b/packages/google-api-core/tests/unit/test_grpc_helpers_otel.py @@ -36,27 +36,22 @@ def test_create_channel_otel_installed_and_enabled(monkeypatch): """Verify that create_channel wraps the channel with OTel interceptor when installed and enabled.""" # Build a hierarchy of mocks to simulate the nested OpenTelemetry modules. - # This allows us to test code that imports these modules without needing them installed. + # MagicMock automatically creates child mocks on attribute access. mock_otel = mock.Mock() - mock_otel_instrumentation = mock.Mock() - mock_otel_grpc = 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.side_effect = lambda ch, inc: f"wrapped_{ch}" - # Link the mocks together to match the package structure (opentelemetry.instrumentation.grpc) - mock_otel.instrumentation = mock_otel_instrumentation - mock_otel_instrumentation.grpc = mock_otel_grpc - # Inject the mocks into sys.modules so Python's import system uses them. - # monkeypatch ensures these changes are reverted after the test finishes. - 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 - ) + 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) # Enable tracing monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")