Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .options import is_signal_enabled

__all__ = ["is_signal_enabled"]
110 changes: 110 additions & 0 deletions packages/google-api-core/google/api_core/observability/options.py
Original file line number Diff line number Diff line change
@@ -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()}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE TO SELF: remove references to option_key throughout.

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
Comment on lines +58 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are two areas of improvement in how client options and signal types are resolved:

  1. Direct Attribute Access via getattr: Accessing __dict__ directly is an implementation detail that bypasses properties (defined with @property) and fails for classes using __slots__. Using getattr(client_options, ...) is the standard, robust way to retrieve attributes from arbitrary objects in Python.
  2. Avoid rstrip for Suffix Removal: rstrip('s') is a common Python gotcha. It removes all trailing occurrences of the character 's' (e.g., 'address' becomes 'addre'). Using slicing or removesuffix is safer.
        option_key = f"enable_{signal_type.lower()}"
        sig_lower = signal_type.lower()
        sig_singular = sig_lower[:-1] if sig_lower.endswith("s") else sig_lower
        provider_key = f"{sig_singular}_provider"

        if isinstance(client_options, dict):
            val = client_options.get(option_key)
            provider = client_options.get(provider_key)
        else:
            val = getattr(client_options, option_key, None)
            provider = getattr(client_options, provider_key, None)

        if val is not None:
            return bool(val)
        if 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")
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
Original file line number Diff line number Diff line change
@@ -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
Loading