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..46f4d5b4a0dc --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -0,0 +1,22 @@ +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", + "set_test_env_override", + "clear_test_env_overrides", + "OtelSpanEnricher", + ] +except ImportError: + __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 new file mode 100644 index 000000000000..c8c0890bf05d --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -0,0 +1,133 @@ +"""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}") + + +_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( + 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/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_options.py b/packages/google-api-core/tests/unit/observability/test_options.py new file mode 100644 index 000000000000..740f7278eda1 --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -0,0 +1,134 @@ +# 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( + "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(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 + ) + 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 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")