From 959e752eeffbb5c8f421e6ea0711980dbc64c8ce Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 11:21:36 -0400 Subject: [PATCH 01/19] 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 071ae3c45d70eaf14ea10d45a094996eea16601b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 14:18:20 -0400 Subject: [PATCH 02/19] 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 626ed58abe403fd8b13f61208302078a8d72762f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 15:02:55 -0400 Subject: [PATCH 03/19] 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 c8670118b7a81c3f9f2491e27b209939d17c05c3 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 09:23:31 -0400 Subject: [PATCH 04/19] 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 3f6ba7889ca16b917ca0fee214a43b973eafe771 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 10:36:23 -0400 Subject: [PATCH 05/19] feat(observability): remove OtelSpanEnricher from current PR --- .../google/api_core/observability/__init__.py | 21 +-- .../google/api_core/observability/tracing.py | 75 -------- .../tests/unit/observability/test_tracing.py | 160 ------------------ 3 files changed, 5 insertions(+), 251 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_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 46f4d5b4a0dc..51330334b981 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -4,19 +4,8 @@ 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", - "set_test_env_override", - "clear_test_env_overrides", - "OtelSpanEnricher", - ] -except ImportError: - __all__ = [ - "is_signal_enabled", - "set_test_env_override", - "clear_test_env_overrides", - ] +__all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", +] 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 9f26b9e7dc62..000000000000 --- a/packages/google-api-core/google/api_core/observability/tracing.py +++ /dev/null @@ -1,75 +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 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/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py deleted file mode 100644 index d1ba00802480..000000000000 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ /dev/null @@ -1,160 +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. - -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 35f073604af506e0c09a67a3292bf41efdadb025 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 16:41:56 -0400 Subject: [PATCH 06/19] test(o11y): add coverage for environment overrides and fallback --- .../tests/unit/observability/test_options.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 44e952af366d..b95b40de1304 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -69,6 +69,30 @@ def test_get_env_bool(monkeypatch): assert _get_env_bool("TEST_VAR") is None +def test_set_test_env_override_clear_specific(): + """Verify that setting an override to None clears that specific override. + + This is important to ensure tests can reset individual environment overrides + without affecting other overrides that might be set for other tests running + concurrently or subsequently. + """ + set_test_env_override("TEST_CLEAR", True) + assert _get_env_bool("TEST_CLEAR") is True + set_test_env_override("TEST_CLEAR", None) + assert _get_env_bool("TEST_CLEAR") is None + + +def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): + """Verify that environment variables without the 'GOOGLE_CLOUD_' prefix fall back directly. + + This is important to ensure that generic or non-GCP environment variables + are handled correctly by the fallback logic without triggering GCP-specific + replacement logic. + """ + monkeypatch.setenv("OTHER_PREFIX_VAR", "true") + assert options._get_env_bool_with_dev_fallback("OTHER_PREFIX_VAR") is True + + @pytest.mark.parametrize( "signal_type, env_vars, client_options, default_val, expected", [ From fa5d234d36d2879d15fff5f211648ff3a63d5c41 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 17:48:00 -0400 Subject: [PATCH 07/19] test(o11y): improve test isolation in environment overrides --- .../tests/unit/observability/test_options.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 b95b40de1304..f2196063f91b 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -76,10 +76,17 @@ def test_set_test_env_override_clear_specific(): without affecting other overrides that might be set for other tests running concurrently or subsequently. """ - set_test_env_override("TEST_CLEAR", True) - assert _get_env_bool("TEST_CLEAR") is True - set_test_env_override("TEST_CLEAR", None) - assert _get_env_bool("TEST_CLEAR") is None + set_test_env_override("TEST_A", True) + set_test_env_override("TEST_B", True) + assert _get_env_bool("TEST_A") is True + assert _get_env_bool("TEST_B") is True + + # Clear only TEST_A + set_test_env_override("TEST_A", None) + + # Verify TEST_A is cleared but TEST_B remains + assert _get_env_bool("TEST_A") is None + assert _get_env_bool("TEST_B") is True def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): From c19700e4fee06756fe27a3ac82485203facfadc2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 6 Jul 2026 08:18:12 -0400 Subject: [PATCH 08/19] test(o11y): eliminate 'traces' references and improve test readability --- .../tests/unit/observability/test_options.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 f2196063f91b..a6686f93ad60 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -32,20 +32,23 @@ def clean_overrides(): @pytest.mark.parametrize( "value,expected", [ + # Truthy values ("y", True), ("yes", True), ("t", True), ("true", True), ("on", True), ("1", True), + (" True ", True), + # Falsy values ("n", False), ("no", False), ("f", False), ("false", False), ("off", False), ("0", False), - (" True ", True), (" FALSE ", False), + # Empty string ("", None), ], ) @@ -132,11 +135,12 @@ def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): False, True, ), - # Programmatic boolean flags are NOT supported (should default/fallback) + # Programmatic boolean flags are NOT supported in client_options + # (should default/fallback to False) ( "tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, - {"enable_traces": True}, + {"enable_metrics": True}, False, False, ), @@ -164,4 +168,4 @@ def test_is_signal_enabled( def test_is_signal_enabled_invalid_signal(): with pytest.raises(ValueError, match="Only 'tracing' is supported"): - options.is_signal_enabled("traces") + options.is_signal_enabled("metrics") From 5aedd86cbd77e152f424be18cda2061291b91f3d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 6 Jul 2026 08:18:21 -0400 Subject: [PATCH 09/19] refactor(o11y): add license header, reorganize variables and improve docstrings --- .../google/api_core/observability/options.py | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 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 b4141424b12b..1be82271144b 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -1,3 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + """Observability environment variable and client options resolution helpers.""" import os @@ -7,6 +23,11 @@ _TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") _FALSY_VALUES = ("n", "no", "f", "false", "off", "0") +# Test-only overrides for environment variables. +# This is intended ONLY for unit/integration testing to prevent mutating +# os.environ. +_TEST_ENV_OVERRIDES: Dict[str, bool] = {} + def _strtobool(val: str) -> Optional[bool]: """Convert a string representation of truth to a boolean.""" @@ -20,9 +41,6 @@ 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. @@ -55,7 +73,7 @@ def _get_env_bool(name: str) -> Optional[bool]: def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: - """Retrieve the boolean value of an environment variable, checking dev/exp fallbacks first.""" + """Retrieve the boolean value of an environment variable, checking experimental fallbacks first.""" if name.startswith("GOOGLE_CLOUD_"): exp_name = name.replace("GOOGLE_CLOUD_", "GOOGLE_CLOUD_EXPERIMENTAL_", 1) val = _get_env_bool(exp_name) @@ -74,12 +92,12 @@ def is_signal_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) + (natively checks for a variant with an "EXPERIMENTAL" token first) 3. Default fallback Args: signal_type: The signal type: must be 'tracing'. - client_options: A dictionary or object representing client configuration. + client_options: A dictionary or object containing client configuration. default: Fallback boolean if no options or env variables match. Returns: @@ -87,7 +105,7 @@ def is_signal_enabled( """ if signal_type != "tracing": raise ValueError( - f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported." + f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported at this time." ) # 1. Resolve Programmatic Options First From 4e495061038a56881779abdb797920add5ce1136 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 6 Jul 2026 08:24:53 -0400 Subject: [PATCH 10/19] chore(o11y): apply black formatting fixes to tests --- .../google-api-core/tests/unit/observability/test_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a6686f93ad60..a687ccd37b5d 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -135,7 +135,7 @@ def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): False, True, ), - # Programmatic boolean flags are NOT supported in client_options + # Programmatic boolean flags are NOT supported in client_options # (should default/fallback to False) ( "tracing", From 4e950c1c6e0ac3c4caa30ee88c9a8c8f79d2eac6 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 14 Jul 2026 19:25:01 -0400 Subject: [PATCH 11/19] feat(otel): align resolve_feature_flags with strict LLD requirements --- .../google/api_core/observability/__init__.py | 4 +- .../google/api_core/observability/options.py | 39 ++++++++------- .../tests/unit/observability/test_options.py | 50 ++++++++++--------- 3 files changed, 50 insertions(+), 43 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 51330334b981..8a3236929cd5 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,11 +1,11 @@ from .options import ( clear_test_env_overrides, - is_signal_enabled, + resolve_feature_flags, set_test_env_override, ) __all__ = [ - "is_signal_enabled", + "resolve_feature_flags", "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 1be82271144b..684461afaa16 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -74,16 +74,16 @@ def _get_env_bool(name: str) -> Optional[bool]: def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: """Retrieve the boolean value of an environment variable, checking experimental fallbacks first.""" - if name.startswith("GOOGLE_CLOUD_"): - exp_name = name.replace("GOOGLE_CLOUD_", "GOOGLE_CLOUD_EXPERIMENTAL_", 1) + if name.startswith("GOOGLE_SDK_"): + exp_name = name.replace("GOOGLE_SDK_", "GOOGLE_SDK_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, +def resolve_feature_flags( + feature_name: str, client_options: Optional[Union[Dict[str, Any], Any]] = None, default: bool = False, ) -> bool: @@ -91,38 +91,43 @@ def is_signal_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 + 2. Language-wide Environment Variable: GOOGLE_SDK_PYTHON_TRACING_ENABLED (natively checks for a variant with an "EXPERIMENTAL" token first) 3. Default fallback Args: - signal_type: The signal type: must be 'tracing'. + feature_name: The feature name: must be 'tracing'. client_options: A dictionary or object containing 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": + if feature_name != "tracing": raise ValueError( - f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported at this time." + f"Invalid feature_name: {feature_name!r}. Only 'tracing' is supported at this time." ) - # 1. Resolve Programmatic Options First + # 1. Experimental Gate + exp_var = "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED" + exp_val = _get_env_bool(exp_var) + + has_provider = False 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 + has_provider = 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 + if exp_val is not True: + if has_provider: + raise ValueError( + f"Experimental feature {feature_name!r} requires {exp_var} to be set to 'true' to use programmatic providers." + ) + return False # Blocked - # 3. Default Fallback - return default + # If we are here, exp_val IS True. + return True 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 a687ccd37b5d..ebd2653fdad1 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -13,7 +13,6 @@ # limitations under the License. import pytest - from google.api_core.observability import options from google.api_core.observability.options import ( _get_env_bool, @@ -104,68 +103,71 @@ def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): @pytest.mark.parametrize( - "signal_type, env_vars, client_options, default_val, expected", + "feature_name, 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), + # Default=True is ignored if blocked + ("tracing", {}, None, True, False), + # Global GA env var is ignored if experimental flag is missing + ("tracing", {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": True}, None, False, False), + ("tracing", {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, None, True, False), # Experimental fallback ( "tracing", - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, + {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, None, False, True, ), ( "tracing", - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": False}, + {"GOOGLE_SDK_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 in client_options # (should default/fallback to False) ( "tracing", - {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, {"enable_metrics": True}, False, False, ), ( "tracing", - {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, {"enable_tracing": True}, False, False, ), ], ) -def test_is_signal_enabled( - signal_type, env_vars, client_options, default_val, expected +def test_resolve_feature_flags( + feature_name, 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 + result = options.resolve_feature_flags( + feature_name, client_options=client_options, default=default_val ) assert result is expected -def test_is_signal_enabled_invalid_signal(): +def test_resolve_feature_flags_invalid_signal(): with pytest.raises(ValueError, match="Only 'tracing' is supported"): - options.is_signal_enabled("metrics") + options.resolve_feature_flags("metrics") + + +def test_resolve_feature_flags_experimental_gate_blocks_provider(): + """Verify that programmatic provider is blocked for experimental features without env var.""" + # Assume 'tracing' is experimental for this test. + # We pass tracer_provider but do NOT set GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED + client_options = {"tracer_provider": object()} + + with pytest.raises(ValueError, match="Experimental feature"): + options.resolve_feature_flags("tracing", client_options=client_options) From c6fe8a6e8db1764aa66637bd9f13a5ec69e3fe32 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 15 Jul 2026 11:18:34 -0400 Subject: [PATCH 12/19] test(o11y): add comprehensive tests for generic feature resolver --- .../tests/unit/observability/test_options.py | 153 ++++++++++-------- 1 file changed, 84 insertions(+), 69 deletions(-) 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 ebd2653fdad1..8293cca41a62 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -91,83 +91,98 @@ def test_set_test_env_override_clear_specific(): assert _get_env_bool("TEST_B") is True -def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): - """Verify that environment variables without the 'GOOGLE_CLOUD_' prefix fall back directly. - This is important to ensure that generic or non-GCP environment variables - are handled correctly by the fallback logic without triggering GCP-specific - replacement logic. - """ - monkeypatch.setenv("OTHER_PREFIX_VAR", "true") - assert options._get_env_bool_with_dev_fallback("OTHER_PREFIX_VAR") is True +def test_resolve_feature_flags_ga_enabled_via_env(): + """Verify that a GA feature is enabled if its environment variable is True.""" + # Setup: We pass a GA environment variable set to True + set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", True) -@pytest.mark.parametrize( - "feature_name, env_vars, client_options, default_val, expected", - [ - # Default fallback tests - ("tracing", {}, None, False, False), - # Default=True is ignored if blocked - ("tracing", {}, None, True, False), - # Global GA env var is ignored if experimental flag is missing - ("tracing", {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": True}, None, False, False), - ("tracing", {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, None, True, False), - # Experimental fallback - ( - "tracing", - {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, - None, - False, - True, - ), - ( - "tracing", - {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": False}, - None, - True, - False, - ), - # Programmatic boolean flags are NOT supported in client_options - # (should default/fallback to False) - ( - "tracing", - {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, - {"enable_metrics": True}, - False, - False, - ), - ( - "tracing", - {"GOOGLE_SDK_PYTHON_TRACING_ENABLED": False}, - {"enable_tracing": True}, - False, - False, - ), - ], -) -def test_resolve_feature_flags( - feature_name, 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) - + # Action result = options.resolve_feature_flags( - feature_name, client_options=client_options, default=default_val + env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=None ) - assert result is expected - -def test_resolve_feature_flags_invalid_signal(): - with pytest.raises(ValueError, match="Only 'tracing' is supported"): - options.resolve_feature_flags("metrics") + # Assertion + assert result is True -def test_resolve_feature_flags_experimental_gate_blocks_provider(): - """Verify that programmatic provider is blocked for experimental features without env var.""" - # Assume 'tracing' is experimental for this test. - # We pass tracer_provider but do NOT set GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED +@pytest.mark.parametrize("gate_value", [None, False]) +def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(gate_value): + """Verify that passing a provider to an experimental feature without the gate raises ValueError.""" + # Setup: Experimental env var is set to gate_value (None means not set) + set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", gate_value) client_options = {"tracer_provider": object()} + # Action & Assertion with pytest.raises(ValueError, match="Experimental feature"): - options.resolve_feature_flags("tracing", client_options=client_options) + options.resolve_feature_flags( + env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=client_options + ) + + +def test_resolve_feature_flags_exp_enabled_with_provider(): + """Verify that experimental feature is enabled if gate is True, even with provider.""" + set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", True) + client_options = {"tracer_provider": object()} + + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=client_options + ) + assert result is True + + +def test_resolve_feature_flags_ga_enabled_via_provider(): + """Verify that a GA feature is enabled if a provider is passed, bypassing env var.""" + # Env var is False, but provider is present + set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", False) + client_options = {"tracer_provider": object()} + + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=client_options + ) + assert result is True + + +@pytest.mark.parametrize("env_val", [None, False], ids=["env_not_set", "env_explicit_false"]) +def test_resolve_feature_flags_ga_fallback_to_false(env_val): + """Verify that a GA feature returns False if no flags are present.""" + set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", env_val) + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=None + ) + assert result is False + + +class _MockOptions: + def __init__(self): + self.other_option = "value" + + +@pytest.mark.parametrize( + "client_options", + [ + {"other_option": "value"}, + _MockOptions(), + ], + ids=["dict_without_key", "object_without_key"] +) +def test_resolve_feature_flags_options_without_key(client_options): + """Verify behavior when client_options is present but missing the provider key.""" + # GA Path: should fall through to env var / fallback + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=client_options + ) + assert result is False From 391fe4cac94c5a46a73ac53840395d7b31bf996c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 15 Jul 2026 11:18:51 -0400 Subject: [PATCH 13/19] feat(o11y): refactor resolve_feature_flags to be generic and strictly follow precedence --- .../google/api_core/observability/options.py | 105 ++++++++++-------- 1 file changed, 61 insertions(+), 44 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 684461afaa16..3eee9df1a5a4 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -72,62 +72,79 @@ def _get_env_bool(name: str) -> Optional[bool]: return None -def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: - """Retrieve the boolean value of an environment variable, checking experimental fallbacks first.""" - if name.startswith("GOOGLE_SDK_"): - exp_name = name.replace("GOOGLE_SDK_", "GOOGLE_SDK_EXPERIMENTAL_", 1) - val = _get_env_bool(exp_name) - if val is not None: - return val - return _get_env_bool(name) +def _has_provider( + client_options: Optional[Union[Dict[str, Any], Any]], provider_key: str +) -> bool: + """Checks if a specific provider key is present and not None in client_options.""" + if client_options is None: + return False + + options_dict = ( + client_options + if isinstance(client_options, dict) + else getattr(client_options, "__dict__", {}) + ) + return options_dict.get(provider_key) is not None def resolve_feature_flags( - feature_name: str, + env_var: str, + provider_key: str, client_options: Optional[Union[Dict[str, Any], Any]] = None, - default: bool = False, ) -> bool: - """Determines if a telemetry signal is enabled. + """Determines if a feature is enabled based on environment variables and client options. + + Behavior depends on whether the `env_var` name contains "EXPERIMENTAL": - Resolves settings in the following order of precedence: - 1. Programmatic overrides in client_options (checks tracer_provider) - 2. Language-wide Environment Variable: GOOGLE_SDK_PYTHON_TRACING_ENABLED - (natively checks for a variant with an "EXPERIMENTAL" token first) - 3. Default fallback + - **Experimental Path** (env_var contains "EXPERIMENTAL"): + Strict gating. Requires the environment variable to be explicitly 'true'. + If a programmatic provider is passed but the environment variable is not 'true', + raises ValueError (Fail Fast). + + - **GA Path** (env_var does not contain "EXPERIMENTAL"): + Standard precedence. Enabled if a programmatic provider is passed, + otherwise falls back to the environment variable value. Args: - feature_name: The feature name: must be 'tracing'. + env_var: The name of the environment variable controlling this feature. + provider_key: The key in client_options/attributes for the programmatic provider. client_options: A dictionary or object containing client configuration. - default: Fallback boolean if no options or env variables match. Returns: - bool: True if the signal is resolved to enabled, False otherwise. + bool: True if the feature is resolved to enabled, False otherwise. + + Raises: + ValueError: If a provider is passed for an experimental feature without opening the gate. """ - if feature_name != "tracing": - raise ValueError( - f"Invalid feature_name: {feature_name!r}. Only 'tracing' is supported at this time." - ) - - # 1. Experimental Gate - exp_var = "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED" - exp_val = _get_env_bool(exp_var) - - has_provider = False - 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: - has_provider = True - - if exp_val is not True: - if has_provider: + + + # Check for programmatic feature provider + has_provider = _has_provider(client_options, provider_key) + + # Read environment variable + env_var_setting = _get_env_bool(env_var) + + # EXPERIMENTAL PATH: + # Resolution Hierarchy: + # 1. EXPERIMENTAL Gate + # 2. Fail Fast if Provider present but EXPERIMENTAL Gate is closed + if "EXPERIMENTAL" in env_var: + # Fail Fast if provider present but gate is closed + if env_var_setting is not True and has_provider: raise ValueError( - f"Experimental feature {feature_name!r} requires {exp_var} to be set to 'true' to use programmatic providers." + f"Experimental feature requires {env_var} to be set to 'true' to use programmatic providers." ) - return False # Blocked - # If we are here, exp_val IS True. - return True + return bool(env_var_setting) + + # GENERAL AVAILABILITY PATH: + # Resolution Hierarchy: + # 1. Programmatic Provider + # 2. Environment Variable + + # Check Programmatic Provider + if has_provider: + return True + + # Check Environment Variable + return bool(env_var_setting) From 0f92c35a157cbce0aa87f9f39d45d66570dd73d2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 15 Jul 2026 11:32:00 -0400 Subject: [PATCH 14/19] style(o11y): apply black formatting to options.py and test_options.py --- .../google/api_core/observability/options.py | 1 - .../tests/unit/observability/test_options.py | 20 +++++++++---------- 2 files changed, 10 insertions(+), 11 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 3eee9df1a5a4..37f8fe9cc70f 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -117,7 +117,6 @@ def resolve_feature_flags( ValueError: If a provider is passed for an experimental feature without opening the gate. """ - # Check for programmatic feature provider has_provider = _has_provider(client_options, provider_key) 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 8293cca41a62..84edcd4fd2ae 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -91,8 +91,6 @@ def test_set_test_env_override_clear_specific(): assert _get_env_bool("TEST_B") is True - - def test_resolve_feature_flags_ga_enabled_via_env(): """Verify that a GA feature is enabled if its environment variable is True.""" # Setup: We pass a GA environment variable set to True @@ -102,7 +100,7 @@ def test_resolve_feature_flags_ga_enabled_via_env(): result = options.resolve_feature_flags( env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=None + client_options=None, ) # Assertion @@ -121,7 +119,7 @@ def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(gate_value): options.resolve_feature_flags( env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=client_options + client_options=client_options, ) @@ -133,7 +131,7 @@ def test_resolve_feature_flags_exp_enabled_with_provider(): result = options.resolve_feature_flags( env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=client_options + client_options=client_options, ) assert result is True @@ -147,19 +145,21 @@ def test_resolve_feature_flags_ga_enabled_via_provider(): result = options.resolve_feature_flags( env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=client_options + client_options=client_options, ) assert result is True -@pytest.mark.parametrize("env_val", [None, False], ids=["env_not_set", "env_explicit_false"]) +@pytest.mark.parametrize( + "env_val", [None, False], ids=["env_not_set", "env_explicit_false"] +) def test_resolve_feature_flags_ga_fallback_to_false(env_val): """Verify that a GA feature returns False if no flags are present.""" set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", env_val) result = options.resolve_feature_flags( env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=None + client_options=None, ) assert result is False @@ -175,7 +175,7 @@ def __init__(self): {"other_option": "value"}, _MockOptions(), ], - ids=["dict_without_key", "object_without_key"] + ids=["dict_without_key", "object_without_key"], ) def test_resolve_feature_flags_options_without_key(client_options): """Verify behavior when client_options is present but missing the provider key.""" @@ -183,6 +183,6 @@ def test_resolve_feature_flags_options_without_key(client_options): result = options.resolve_feature_flags( env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", provider_key="tracer_provider", - client_options=client_options + client_options=client_options, ) assert result is False From 2fb005782dd6b44cf1bebd8cdfc80d0f41928974 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 16 Jul 2026 12:48:33 -0400 Subject: [PATCH 15/19] docs(o11y): revise terminology to remove 'gate' metaphor --- .../google/api_core/observability/options.py | 10 +++++----- .../tests/unit/observability/test_options.py | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 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 37f8fe9cc70f..5dbd1e4ab5f5 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -97,7 +97,7 @@ def resolve_feature_flags( Behavior depends on whether the `env_var` name contains "EXPERIMENTAL": - **Experimental Path** (env_var contains "EXPERIMENTAL"): - Strict gating. Requires the environment variable to be explicitly 'true'. + Strict control. Requires the environment variable to be explicitly 'true'. If a programmatic provider is passed but the environment variable is not 'true', raises ValueError (Fail Fast). @@ -114,7 +114,7 @@ def resolve_feature_flags( bool: True if the feature is resolved to enabled, False otherwise. Raises: - ValueError: If a provider is passed for an experimental feature without opening the gate. + ValueError: If a provider is provided for an experimental feature without enabling the experimental environment variable. """ # Check for programmatic feature provider @@ -125,10 +125,10 @@ def resolve_feature_flags( # EXPERIMENTAL PATH: # Resolution Hierarchy: - # 1. EXPERIMENTAL Gate - # 2. Fail Fast if Provider present but EXPERIMENTAL Gate is closed + # 1. EXPERIMENTAL Environment Variable + # 2. Fail Fast if Provider present but EXPERIMENTAL Environment Variable is not enabled if "EXPERIMENTAL" in env_var: - # Fail Fast if provider present but gate is closed + # Fail Fast if provider present but experimental environment variable is not enabled if env_var_setting is not True and has_provider: raise ValueError( f"Experimental feature requires {env_var} to be set to 'true' to use programmatic providers." 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 84edcd4fd2ae..53caa83c8534 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -107,11 +107,11 @@ def test_resolve_feature_flags_ga_enabled_via_env(): assert result is True -@pytest.mark.parametrize("gate_value", [None, False]) -def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(gate_value): - """Verify that passing a provider to an experimental feature without the gate raises ValueError.""" - # Setup: Experimental env var is set to gate_value (None means not set) - set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", gate_value) +@pytest.mark.parametrize("exp_env_state", [None, False], ids=["missing", "disabled"]) +def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(exp_env_state): + """Verify that passing a provider to an experimental feature raises ValueError if the experimental environment variable is disabled or missing.""" + # Setup: Experimental env var is set to exp_env_state (None means not set) + set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", exp_env_state) client_options = {"tracer_provider": object()} # Action & Assertion @@ -124,7 +124,7 @@ def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(gate_value): def test_resolve_feature_flags_exp_enabled_with_provider(): - """Verify that experimental feature is enabled if gate is True, even with provider.""" + """Verify that experimental feature is enabled if the experimental environment variable is enabled and a provider is provided.""" set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", True) client_options = {"tracer_provider": object()} @@ -137,7 +137,7 @@ def test_resolve_feature_flags_exp_enabled_with_provider(): def test_resolve_feature_flags_ga_enabled_via_provider(): - """Verify that a GA feature is enabled if a provider is passed, bypassing env var.""" + """Verify that a GA feature is enabled if a provider is provided, ignoring the environment variable.""" # Env var is False, but provider is present set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", False) client_options = {"tracer_provider": object()} @@ -154,7 +154,7 @@ def test_resolve_feature_flags_ga_enabled_via_provider(): "env_val", [None, False], ids=["env_not_set", "env_explicit_false"] ) def test_resolve_feature_flags_ga_fallback_to_false(env_val): - """Verify that a GA feature returns False if no flags are present.""" + """Verify that a GA feature is disabled if neither a provider is provided nor the environment variable is enabled.""" set_test_env_override("GOOGLE_SDK_PYTHON_TRACING_ENABLED", env_val) result = options.resolve_feature_flags( env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED", From 57014abcec62b0bbb7044c42dad9aedabc7a8988 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 16 Jul 2026 12:48:44 -0400 Subject: [PATCH 16/19] fix(o11y): add warning for malformed environment variables --- .../google-api-core/google/api_core/observability/options.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 5dbd1e4ab5f5..6b3620ffc193 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -17,6 +17,7 @@ """Observability environment variable and client options resolution helpers.""" import os +import warnings from typing import Any, Dict, Optional, Union # Allowed truthy and falsy patterns for environment variables @@ -68,7 +69,8 @@ def _get_env_bool(name: str) -> Optional[bool]: return None try: return _strtobool(val) - except ValueError: + except ValueError as e: + warnings.warn(f"Ignored invalid value for {name}: {e}", RuntimeWarning) return None From 1c1786366a63242a28e032ee02bb4bb8d30a4e75 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 16 Jul 2026 12:48:53 -0400 Subject: [PATCH 17/19] refactor(o11y): make _has_provider more robust by using getattr --- .../google/api_core/observability/options.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 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 6b3620ffc193..e566832c1a56 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -81,12 +81,10 @@ def _has_provider( if client_options is None: return False - options_dict = ( - client_options - if isinstance(client_options, dict) - else getattr(client_options, "__dict__", {}) - ) - return options_dict.get(provider_key) is not None + if isinstance(client_options, dict): + return client_options.get(provider_key) is not None + + return getattr(client_options, provider_key, None) is not None def resolve_feature_flags( From 8dd6175d64b69ddbbbbfff55a0e032f494361f52 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 16 Jul 2026 12:49:03 -0400 Subject: [PATCH 18/19] test(o11y): add missing tests for experimental path without provider --- .../tests/unit/observability/test_options.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 53caa83c8534..d19c29fa43dc 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -136,6 +136,30 @@ def test_resolve_feature_flags_exp_enabled_with_provider(): assert result is True +def test_resolve_feature_flags_exp_enabled_without_provider(): + """Verify that experimental feature is enabled if the experimental environment variable is enabled and NO provider is provided.""" + set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", True) + + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=None, + ) + assert result is True + + +def test_resolve_feature_flags_exp_disabled_without_provider(): + """Verify that experimental feature is disabled if the experimental environment variable is disabled and NO provider is provided.""" + set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", False) + + result = options.resolve_feature_flags( + env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", + provider_key="tracer_provider", + client_options=None, + ) + assert result is False + + def test_resolve_feature_flags_ga_enabled_via_provider(): """Verify that a GA feature is enabled if a provider is provided, ignoring the environment variable.""" # Env var is False, but provider is present From 9fc0088f17cbf11851d867fd5f81c6f32d677368 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 16 Jul 2026 12:51:05 -0400 Subject: [PATCH 19/19] style(o11y): apply black formatting to test_options.py --- .../tests/unit/observability/test_options.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 d19c29fa43dc..5c4864bbdfec 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -65,7 +65,10 @@ def test_get_env_bool(monkeypatch): assert _get_env_bool("TEST_VAR") is True monkeypatch.setenv("TEST_VAR", "invalid") - assert _get_env_bool("TEST_VAR") is None + import pytest + + with pytest.warns(RuntimeWarning, match="Ignored invalid value"): + assert _get_env_bool("TEST_VAR") is None monkeypatch.delenv("TEST_VAR", raising=False) assert _get_env_bool("TEST_VAR") is None @@ -111,7 +114,9 @@ def test_resolve_feature_flags_ga_enabled_via_env(): def test_resolve_feature_flags_exp_blocked_with_provider_fails_fast(exp_env_state): """Verify that passing a provider to an experimental feature raises ValueError if the experimental environment variable is disabled or missing.""" # Setup: Experimental env var is set to exp_env_state (None means not set) - set_test_env_override("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", exp_env_state) + set_test_env_override( + "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", exp_env_state + ) client_options = {"tracer_provider": object()} # Action & Assertion