From 1231c5ac1d8ec8c16eb018b3a631eccf3cf59889 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 11:21:36 -0400 Subject: [PATCH 1/7] 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 3915ae2afd091fe57d9ef33c02c24f9780b7f95c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 14:18:20 -0400 Subject: [PATCH 2/7] 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 28c42be84295..64719047b689 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'", "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 4b3f2d263eef..72bbac9f82f5 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.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 +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 f1b6af2fcd94..5713bdc6163b 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.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 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 3a2248ce2787edefc89171ec13e23bdea3f8a931 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 15:02:55 -0400 Subject: [PATCH 3/7] 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 39cdcd5ae616a2e8cb5d9f75bcc33e2555578a67 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 09:23:31 -0400 Subject: [PATCH 4/7] 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 64719047b689..2490acb60697 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'", "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"] @@ -95,4 +95,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 72bbac9f82f5..0627965b8545 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.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 -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 5713bdc6163b..bbb332b89bca 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.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 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 26e8fc18dc6becd5a8527ee8d8624196b70a528d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 10:58:54 -0400 Subject: [PATCH 5/7] 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 91e5ce212e447a5da41ae166589b3e56274b4b19 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 11:05:30 -0400 Subject: [PATCH 6/7] 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 98b4f275654b0f58621a3e7c7788f5a1ba9c6a9d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 24 Jul 2026 11:13:46 -0400 Subject: [PATCH 7/7] 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