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..aea71d26d164 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -0,0 +1,9 @@ +try: + # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. + from .tracing import OtelUnaryClientInterceptor # noqa: F401 + + __all__ = [ + "OtelUnaryClientInterceptor", + ] +except ImportError: + __all__ = [] 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..64a01887ba10 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/tracing.py @@ -0,0 +1,96 @@ +# 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 OtelUnaryClientInterceptor(grpc.UnaryUnaryClientInterceptor): + """A gRPC client interceptor that creates OpenTelemetry spans for outgoing requests. + + This interceptor explicitly creates a standard SpanKind.CLIENT span for each network attempt + and enriches it with standard Google Cloud attributes. + """ + + def __init__( + self, + static_attributes: Optional[Dict[str, Any]] = None, + ): + """Initializes the OtelUnaryClientInterceptor. + + Args: + static_attributes: Standard static attributes to attach to every span. + E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} + """ + self._static_attributes = static_attributes or {} + + def intercept_unary_unary( + self, + continuation: Callable[[grpc.ClientCallDetails, Any], Any], + client_call_details: grpc.ClientCallDetails, + request: Any, + ) -> Any: + from google.api_core._feature_gating_helpers import resolve_feature_flags + + # For now, we only check environment variables as we don't have access to ClientOptions here. + # To support programmatic configuration, we would need to pass it during Client + # initialization. + # TODO: we need to refactor resolve_feature_flags to allows feature_key to be optional. + enabled = resolve_feature_flags( + env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", + feature_key="tracer_provider", + ) + + if not enabled: + return continuation(client_call_details, request) + + tracer = trace.get_tracer(__name__) + + # Determine span name (e.g., from client_call_details.method) + span_name = client_call_details.method + + with tracer.start_as_current_span( + span_name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + # Inject static attributes + for key, val in self._static_attributes.items(): + span.set_attribute(key, val) + + # Extract dynamic attributes from metadata + for key, value in client_call_details.metadata: + if key == "x-goog-request-params": + try: + # x-goog-request-params is urlencoded string of key=value pairs separated by & + params = dict( + p.split("=") for p in value.split("&") if "=" in p + ) + + # Standard resource identifiers are usually in 'name' or 'parent' + resource_id = params.get("name") or params.get("parent") + if resource_id: + span.set_attribute( + "gcp.resource.destination.id", resource_id + ) + except Exception: + # Fail open if parsing fails to avoid breaking the request + pass + + span.set_attribute("rpc.system.name", "grpc") + + return continuation(client_call_details, request) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 28c42be84295..2490acb60697 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.27.0, < 2.0.0", ] dynamic = ["version"] @@ -94,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 4b3f2d263eef..0627965b8545 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.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 f1b6af2fcd94..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,3 +13,4 @@ grpcio==1.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 aiohttp==3.13.4 +opentelemetry-api==1.27.0 diff --git a/packages/google-api-core/tests/unit/observability/test_init.py b/packages/google-api-core/tests/unit/observability/test_init.py new file mode 100644 index 000000000000..f11022fae1ba --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_init.py @@ -0,0 +1,53 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for google.api_core.observability.__init__.""" + +import importlib +import sys + + +def test_init_exports(): + import google.api_core.observability + + # Check if dependencies are available + try: + import grpc # noqa: F401 + import opentelemetry.trace # noqa: F401 + + has_deps = True + except ImportError: + has_deps = False + + if has_deps: + assert "OtelUnaryClientInterceptor" in google.api_core.observability.__all__ + else: + assert google.api_core.observability.__all__ == [] + + +def test_init_import_error_forced(monkeypatch): + """Verifies behavior when tracing module fails to import, even if deps are present.""" + import google.api_core.observability + + # Poison the tracing module + monkeypatch.setitem(sys.modules, "google.api_core.observability.tracing", None) + + # Reload observability, it should fail to import tracing and trigger except block + importlib.reload(google.api_core.observability) + + assert google.api_core.observability.__all__ == [] + + # Clean up + monkeypatch.undo() + importlib.reload(google.api_core.observability) diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py new file mode 100644 index 000000000000..fc21fe4fbb1f --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -0,0 +1,220 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for OpenTelemetry Tracing Interceptors.""" + +from unittest.mock import MagicMock, Mock + +import pytest + +has_deps = True +try: + import grpc # noqa: F401 + from opentelemetry import trace # noqa: F401 +except ImportError: + has_deps = False + +pytestmark = pytest.mark.skipif( + not has_deps, reason="Skipping gRPC/OTel tests because dependencies are missing" +) + + +class MockClientCallDetails: + def __init__( + self, + method="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", + ): + self.method = method + self.timeout = None + self.metadata = [] + self.credentials = None + self.wait_for_ready = None + + +@pytest.fixture +def mock_tracer(mocker): + """Mocks tracer and start_as_current_span context manager.""" + mock_tracer_obj = MagicMock() + mock_span_obj = MagicMock() + + # Configure start_as_current_span to act as a context manager returning mock_span_obj + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_span_obj + mock_tracer_obj.start_as_current_span.return_value = mock_cm + + mocker.patch("opentelemetry.trace.get_tracer", return_value=mock_tracer_obj) + return mock_tracer_obj, mock_span_obj + + +def test_interceptor_creates_span(mock_tracer, monkeypatch): + """F1.7 (Partial): Verifies that the interceptor creates a CLIENT span with the correct name.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + + interceptor = OtelUnaryClientInterceptor() + + continuation = Mock(return_value="response") + details = MockClientCallDetails(method="/MyService/MyMethod") + request = "request_payload" + + res = interceptor.intercept_unary_unary(continuation, details, request) + + assert res == "response" + + # Verify span creation + mock_tracer_obj.start_as_current_span.assert_called_once_with( + "/MyService/MyMethod", kind=trace.SpanKind.CLIENT + ) + + # Verify continuation was called + continuation.assert_called_once_with(details, request) + + +def test_interceptor_disabled(mock_tracer, monkeypatch): + """F1.6: Verifies that the interceptor does NOT create a span if disabled.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false") + + mock_tracer_obj, _ = mock_tracer + + interceptor = OtelUnaryClientInterceptor() + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request_payload" + + res = interceptor.intercept_unary_unary(continuation, details, request) + + assert res == "response" + + # Verify NO span creation + mock_tracer_obj.start_as_current_span.assert_not_called() + + # Verify continuation was called + continuation.assert_called_once_with(details, request) + + +def test_interceptor_adds_static_attributes(mock_tracer, monkeypatch): + """Verifies that static attributes are added to the span.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + + static_attrs = {"gcp.client.repo": "googleapis/google-cloud-python"} + interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request_payload" + + interceptor.intercept_unary_unary(continuation, details, request) + + # Verify attributes set + mock_span_obj.set_attribute.assert_any_call( + "gcp.client.repo", "googleapis/google-cloud-python" + ) + mock_span_obj.set_attribute.assert_any_call("rpc.system.name", "grpc") + + +def test_interceptor_non_recording_span(mock_tracer, monkeypatch): + """Verifies that non-recording spans skip attribute injection.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = False + + static_attrs = {"static.key": "static.val"} + interceptor = OtelUnaryClientInterceptor(static_attributes=static_attrs) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request_payload" + + interceptor.intercept_unary_unary(continuation, details, request) + + # Verify set_attribute was NOT called + mock_span_obj.set_attribute.assert_not_called() + + +@pytest.mark.parametrize( + "metadata,expected_destination_id", + [ + # Case A: Success with 'name' + ( + [ + ( + "x-goog-request-params", + "name=projects/my-project/secrets/my-secret/versions/1&other=val", + ) + ], + "projects/my-project/secrets/my-secret/versions/1", + ), + # Case B: Success with 'parent' + ( + [ + ( + "x-goog-request-params", + "parent=projects/my-project/locations/us-central1&other=val", + ) + ], + "projects/my-project/locations/us-central1", + ), + # Case C: Other metadata keys (Loop continues, no destination id) + ([("some-other-header", "value")], None), + # Case D: x-goog-request-params exists but no name/parent + ([("x-goog-request-params", "other=val")], None), + # Case E: Malformed x-goog-request-params (Exception caught, fails open) + ([("x-goog-request-params", "name=foo=bar")], None), + ], +) +def test_interceptor_metadata_parsing( + mock_tracer, monkeypatch, metadata, expected_destination_id +): + """F1.7 (Partial): Verifies metadata parsing scenarios, including success, missing keys, and malformed data.""" + from google.api_core.observability.tracing import OtelUnaryClientInterceptor + + monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true") + + mock_tracer_obj, mock_span_obj = mock_tracer + mock_span_obj.is_recording.return_value = True + + interceptor = OtelUnaryClientInterceptor() + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + details.metadata = metadata + request = "request_payload" + + interceptor.intercept_unary_unary(continuation, details, request) + + if expected_destination_id: + mock_span_obj.set_attribute.assert_any_call( + "gcp.resource.destination.id", expected_destination_id + ) + else: + # Verify gcp.resource.destination.id was NOT called + called_keys = [ + call[0][0] for call in mock_span_obj.set_attribute.call_args_list + ] + assert "gcp.resource.destination.id" not in called_keys