From 4b18e9f031f256b44942986a0cc58f2a837e1f81 Mon Sep 17 00:00:00 2001 From: Shradha Katyal Date: Wed, 23 Sep 2026 08:19:13 +0000 Subject: [PATCH] feat(storage): add OpenTelemetry metrics gating and configuration module - Add _opentelemetry_metrics.py with internal dev gate and environment variable parsing - Implement is_metrics_enabled and is_advanced_metrics_enabled evaluation helpers - Provide get_common_attributes and get_meter helpers for OpenTelemetry metrics - Add comprehensive unit tests in test__opentelemetry_metrics.py --- .../cloud/storage/_opentelemetry_metrics.py | 140 ++++++++++++ .../tests/unit/test__opentelemetry_metrics.py | 213 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 packages/google-cloud-storage/google/cloud/storage/_opentelemetry_metrics.py create mode 100644 packages/google-cloud-storage/tests/unit/test__opentelemetry_metrics.py diff --git a/packages/google-cloud-storage/google/cloud/storage/_opentelemetry_metrics.py b/packages/google-cloud-storage/google/cloud/storage/_opentelemetry_metrics.py new file mode 100644 index 000000000000..38b08ce2965d --- /dev/null +++ b/packages/google-cloud-storage/google/cloud/storage/_opentelemetry_metrics.py @@ -0,0 +1,140 @@ +# 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. + +"""Manages OpenTelemetry metrics instruments and gating for GCS client.""" + +import logging +import os +from typing import Any, Dict, Optional + +from google.cloud.storage.version import __version__ + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# 1. Hidden Development Gate +# --------------------------------------------------------------------------- +# Must remain False in production branches. +# Only enabled during active development and test runs. +_ENABLE_METRICS_DEV_GATE = False + +# --------------------------------------------------------------------------- +# 2. Standardized Configuration and Environment Variable Names +# --------------------------------------------------------------------------- +ENABLE_OTEL_METRICS_ENV_VAR = "GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS" +ENABLE_DEBUG_METRICS_ENV_VAR = "GCP_STORAGE_PYTHON_ENABLE_DEBUG_METRICS" + +_DEFAULT_ENABLE_METRICS = False +_DEFAULT_ENABLE_DEBUG_METRICS = False + +# --------------------------------------------------------------------------- +# 3. Optional OpenTelemetry Dependency Check +# --------------------------------------------------------------------------- +try: + from opentelemetry import metrics + + HAS_OPENTELEMETRY_METRICS = True +except ImportError: + HAS_OPENTELEMETRY_METRICS = False + logger.debug( + "OpenTelemetry metrics package (opentelemetry-api >= 1.12.0) is not " + "installed. GCS client metrics are disabled." + ) + + +def _parse_bool_env(name: str, default: bool = False) -> bool: + """Parses a boolean from an environment variable.""" + val = os.environ.get(name) + if val is None: + return default + return str(val).strip().lower() in {"1", "true", "yes", "on"} + + +def is_metrics_enabled(client_setting: Optional[bool] = None) -> bool: + """Evaluates whether standard GCS metrics should be recorded. + + Args: + client_setting: Optional boolean configured on the client instance. + Takes precedence over the environment variable if specified. + + Returns: + bool: True if metrics recording is enabled, False otherwise. + """ + if client_setting is not None and not isinstance(client_setting, bool): + raise TypeError("enable_metrics must be a boolean or None.") + + if not HAS_OPENTELEMETRY_METRICS: + return False + + if not _ENABLE_METRICS_DEV_GATE: + return False + + if client_setting is not None: + return client_setting + + return _parse_bool_env(ENABLE_OTEL_METRICS_ENV_VAR, _DEFAULT_ENABLE_METRICS) + + +def is_advanced_metrics_enabled( + client_setting: Optional[bool] = None, + base_setting: Optional[bool] = None, +) -> bool: + """Evaluates whether high-frequency debug metrics should be recorded. + + Args: + client_setting: Optional boolean configured on the client instance. + Takes precedence over the environment variable if specified. + base_setting: Optional boolean configured on the client instance for + base metrics. + + Returns: + bool: True if advanced metrics recording is enabled, False otherwise. + """ + if client_setting is not None and not isinstance(client_setting, bool): + raise TypeError("enable_advanced_metrics must be a boolean or None.") + + if not is_metrics_enabled(base_setting): + return False + + if client_setting is not None: + return client_setting + + return _parse_bool_env(ENABLE_DEBUG_METRICS_ENV_VAR, _DEFAULT_ENABLE_DEBUG_METRICS) + + +# --------------------------------------------------------------------------- +# 4. Standard Common Attributes & Meter Provider +# --------------------------------------------------------------------------- +_COMMON_ATTRIBUTES: Dict[str, Any] = { + "gcp.client.service": "storage", + "gcp.client.version": __version__, + "gcp.client.repo": "googleapis/google-cloud-python", + "gcp.client.artifact": "google-cloud-storage", +} + + +def get_common_attributes() -> Dict[str, Any]: + """Returns a copy of standard GCS client attributes for metrics.""" + return _COMMON_ATTRIBUTES.copy() + + +def get_meter(meter_provider: Optional[Any] = None) -> Optional[Any]: + """Returns the OpenTelemetry Meter for Google Cloud Storage.""" + if not HAS_OPENTELEMETRY_METRICS or not _ENABLE_METRICS_DEV_GATE: + return None + return metrics.get_meter( + "google.cloud.storage", + __version__, + meter_provider=meter_provider, + ) diff --git a/packages/google-cloud-storage/tests/unit/test__opentelemetry_metrics.py b/packages/google-cloud-storage/tests/unit/test__opentelemetry_metrics.py new file mode 100644 index 000000000000..6f0a191dfa78 --- /dev/null +++ b/packages/google-cloud-storage/tests/unit/test__opentelemetry_metrics.py @@ -0,0 +1,213 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +import sys +from unittest import mock + +import pytest + +from google.cloud.storage import _opentelemetry_metrics +from google.cloud.storage.version import __version__ + + +@pytest.fixture(autouse=True) +def setup(monkeypatch): + """Default HAS_OPENTELEMETRY_METRICS to True for gating logic tests.""" + monkeypatch.setattr(_opentelemetry_metrics, "HAS_OPENTELEMETRY_METRICS", True) + + +def test_opentelemetry_import_error_on_load(monkeypatch): + """Verify module import gracefully handles missing opentelemetry package.""" + with monkeypatch.context() as m: + m.setitem(sys.modules, "opentelemetry", None) + importlib.reload(_opentelemetry_metrics) + assert _opentelemetry_metrics.HAS_OPENTELEMETRY_METRICS is False + assert _opentelemetry_metrics.get_meter() is None + importlib.reload(_opentelemetry_metrics) + + +@pytest.mark.parametrize( + "env_val,default,expected", + [ + ("1", False, True), + ("true", False, True), + ("True", False, True), + ("yes", False, True), + ("on", False, True), + ("0", True, False), + ("false", True, False), + ("no", True, False), + ("off", True, False), + ("invalid", False, False), + ], +) +def test_parse_bool_env(monkeypatch, env_val, default, expected): + monkeypatch.setenv("TEST_BOOL_ENV", env_val) + assert _opentelemetry_metrics._parse_bool_env("TEST_BOOL_ENV", default) == expected + + +def test_parse_bool_env_default(monkeypatch): + monkeypatch.delenv("TEST_BOOL_ENV_MISSING", raising=False) + assert _opentelemetry_metrics._parse_bool_env("TEST_BOOL_ENV_MISSING", True) is True + assert ( + _opentelemetry_metrics._parse_bool_env("TEST_BOOL_ENV_MISSING", False) is False + ) + + +def test_dev_gate_locked_disables_metrics(monkeypatch): + """When _ENABLE_METRICS_DEV_GATE is False, metrics must remain disabled.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", False) + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + + assert _opentelemetry_metrics.is_metrics_enabled() is False + assert _opentelemetry_metrics.is_metrics_enabled(client_setting=True) is False + + +def test_dev_gate_unlocked_respects_env_var(monkeypatch): + """When dev gate is open, environment variable enables metrics.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + assert _opentelemetry_metrics.is_metrics_enabled() is True + + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "false") + assert _opentelemetry_metrics.is_metrics_enabled() is False + + +def test_metrics_default_is_disabled_when_env_unset(monkeypatch): + """When env var is unset, default value is False.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + monkeypatch.delenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", raising=False) + + assert _opentelemetry_metrics.is_metrics_enabled() is False + + +def test_client_setting_overrides_env_var(monkeypatch): + """Client constructor parameter must take precedence over env var.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + + # Client disables while env var is True + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + assert _opentelemetry_metrics.is_metrics_enabled(client_setting=False) is False + + # Client enables while env var is False + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "false") + assert _opentelemetry_metrics.is_metrics_enabled(client_setting=True) is True + + +def test_non_boolean_client_setting_raises_type_error(): + """Passing a non-boolean setting such as 'false' must raise TypeError.""" + with pytest.raises(TypeError, match="enable_metrics must be a boolean or None"): + _opentelemetry_metrics.is_metrics_enabled(client_setting="false") # type: ignore[arg-type] + + with pytest.raises( + TypeError, match="enable_advanced_metrics must be a boolean or None" + ): + _opentelemetry_metrics.is_advanced_metrics_enabled(client_setting="false") # type: ignore[arg-type] + + +def test_advanced_metrics_requires_base_metrics(monkeypatch): + """Advanced metrics cannot be active if base metrics are disabled.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "false") + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_DEBUG_METRICS", "true") + + assert _opentelemetry_metrics.is_advanced_metrics_enabled() is False + assert ( + _opentelemetry_metrics.is_advanced_metrics_enabled( + client_setting=True, base_setting=False + ) + is False + ) + + +def test_advanced_metrics_enabled(monkeypatch): + """Advanced metrics is enabled when both base and debug flags are True.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_DEBUG_METRICS", "true") + + assert _opentelemetry_metrics.is_advanced_metrics_enabled() is True + + +def test_advanced_metrics_client_setting_overrides_env_var(monkeypatch): + """Client setting overrides advanced metrics env var in both directions.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + + # Client enables while debug env var is False + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_DEBUG_METRICS", "false") + assert ( + _opentelemetry_metrics.is_advanced_metrics_enabled(client_setting=True) is True + ) + + # Client disables while debug env var is True + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_DEBUG_METRICS", "true") + assert ( + _opentelemetry_metrics.is_advanced_metrics_enabled(client_setting=False) + is False + ) + + # Base client setting enables even when base env var is False + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "false") + assert ( + _opentelemetry_metrics.is_advanced_metrics_enabled( + client_setting=True, base_setting=True + ) + is True + ) + + +def test_otel_missing_disables_metrics(monkeypatch): + """If opentelemetry-api is not installed, metrics must gracefully disable.""" + monkeypatch.setattr(_opentelemetry_metrics, "HAS_OPENTELEMETRY_METRICS", False) + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + monkeypatch.setenv("GCP_STORAGE_PYTHON_ENABLE_OTEL_METRICS", "true") + + assert _opentelemetry_metrics.is_metrics_enabled() is False + assert _opentelemetry_metrics.get_meter() is None + + +def test_get_common_attributes(): + """Verify common attributes conform to GCS OTel specification.""" + attrs = _opentelemetry_metrics.get_common_attributes() + assert attrs["gcp.client.service"] == "storage" + assert attrs["gcp.client.version"] == __version__ + assert attrs["gcp.client.repo"] == "googleapis/google-cloud-python" + assert attrs["gcp.client.artifact"] == "google-cloud-storage" + + attrs["gcp.client.service"] = "mutated" + assert ( + _opentelemetry_metrics.get_common_attributes()["gcp.client.service"] + == "storage" + ) + + +def test_get_meter(monkeypatch): + """Verify get_meter respects dev gate and forwards meter_provider.""" + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", False) + assert _opentelemetry_metrics.get_meter() is None + + mock_metrics = mock.Mock() + monkeypatch.setattr(_opentelemetry_metrics, "metrics", mock_metrics, raising=False) + monkeypatch.setattr(_opentelemetry_metrics, "_ENABLE_METRICS_DEV_GATE", True) + mock_provider = mock.Mock() + meter = _opentelemetry_metrics.get_meter(meter_provider=mock_provider) + assert meter is not None + mock_metrics.get_meter.assert_called_once_with( + "google.cloud.storage", + __version__, + meter_provider=mock_provider, + )