Skip to content

Commit 09e8420

Browse files
committed
feat(observability): add base OpenTelemetry span enricher interceptor
1 parent 7605848 commit 09e8420

6 files changed

Lines changed: 245 additions & 1 deletion

File tree

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
from .options import is_signal_enabled
22

3-
__all__ = ["is_signal_enabled"]
3+
try:
4+
# Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace.
5+
from .tracing import OtelSpanEnricher # noqa: F401
6+
7+
__all__ = ["is_signal_enabled", "OtelSpanEnricher"]
8+
except ImportError:
9+
__all__ = ["is_signal_enabled"]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""OpenTelemetry Tracing Enrichment Interceptors."""
16+
17+
from typing import Any, Callable, Dict, Optional
18+
19+
import grpc
20+
from opentelemetry import trace
21+
22+
23+
class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor):
24+
"""A gRPC client interceptor that enriches the active OpenTelemetry span.
25+
26+
This interceptor relies on the standard OpenTelemetry gRPC instrumentor
27+
to create the baseline span. It runs in the interceptor chain to inject
28+
additional Google Cloud specific domain attributes.
29+
"""
30+
31+
def __init__(
32+
self,
33+
static_attributes: Optional[Dict[str, Any]] = None,
34+
attribute_extractor: Optional[
35+
Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]]
36+
] = None,
37+
):
38+
"""Initializes the OtelSpanEnricher.
39+
40+
Args:
41+
static_attributes: Standard static attributes to attach to every span.
42+
E.g. {"gcp.client.repo": "googleapis/google-cloud-python"}
43+
attribute_extractor: A callable that extracts dynamic attributes from
44+
the request and client call details.
45+
"""
46+
self._static_attributes = static_attributes or {}
47+
self._attribute_extractor = attribute_extractor
48+
49+
def intercept_unary_unary(
50+
self,
51+
continuation: Callable[[grpc.ClientCallDetails, Any], Any],
52+
client_call_details: grpc.ClientCallDetails,
53+
request: Any,
54+
) -> Any:
55+
span = trace.get_current_span()
56+
57+
if span.is_recording():
58+
# Inject static attributes
59+
for key, val in self._static_attributes.items():
60+
span.set_attribute(key, val)
61+
62+
# Extract and inject dynamic attributes
63+
if self._attribute_extractor:
64+
try:
65+
dynamic_attrs = self._attribute_extractor(
66+
request, client_call_details
67+
)
68+
for key, val in dynamic_attrs.items():
69+
if val is not None:
70+
span.set_attribute(key, val)
71+
except Exception:
72+
# Prevent custom extractor exceptions from failing the RPC
73+
pass
74+
75+
return continuation(client_call_details, request)

packages/google-api-core/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ dependencies = [
4949
"proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'",
5050
"google-auth >= 2.14.1, < 3.0.0",
5151
"requests >= 2.33.0, < 3.0.0",
52+
"opentelemetry-api >= 1.1.0, < 2.0.0",
5253
]
5354
dynamic = ["version"]
5455

packages/google-api-core/testing/constraints-3.10.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ requests==2.33.0
1212
grpcio==1.41.0
1313
grpcio-status==1.41.0
1414
proto-plus==1.24.0
15+
opentelemetry-api==1.1.0

packages/google-api-core/testing/constraints-async-rest-3.10.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ grpcio==1.41.0
1313
grpcio-status==1.41.0
1414
proto-plus==1.24.0
1515
aiohttp==3.13.4
16+
opentelemetry-api==1.1.0
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from unittest.mock import MagicMock, Mock
16+
17+
import pytest
18+
19+
# Check if grpc is available
20+
try:
21+
import grpc
22+
23+
has_grpc = True
24+
except ImportError:
25+
has_grpc = False
26+
27+
# Skip all tests in this module if grpc is not installed
28+
pytestmark = pytest.mark.skipif(not has_grpc, reason="grpc package is required")
29+
30+
if has_grpc:
31+
32+
class MockClientCallDetails(grpc.ClientCallDetails):
33+
pass
34+
35+
else:
36+
# Tell mypy that we are intentionally redefining this class for the non-gRPC fallback path.
37+
class MockClientCallDetails: # type: ignore[no-redef]
38+
pass
39+
40+
41+
@pytest.fixture
42+
def mock_span(mocker):
43+
"""Mocks trace.get_current_span to return a recording span."""
44+
mock_span_obj = MagicMock()
45+
mock_span_obj.is_recording.return_value = True
46+
mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj)
47+
return mock_span_obj
48+
49+
50+
@pytest.fixture
51+
def mock_span_non_recording(mocker):
52+
"""Mocks trace.get_current_span to return a non-recording span."""
53+
mock_span_obj = MagicMock()
54+
mock_span_obj.is_recording.return_value = False
55+
mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj)
56+
return mock_span_obj
57+
58+
59+
def test_enricher_non_recording_span(mock_span_non_recording):
60+
"""Verifies that non-recording spans do not have attributes set and extractor is skipped."""
61+
from google.api_core.observability.tracing import OtelSpanEnricher
62+
63+
extractor = Mock()
64+
enricher = OtelSpanEnricher(
65+
static_attributes={"static.key": "static.val"}, attribute_extractor=extractor
66+
)
67+
68+
continuation = Mock(return_value="response")
69+
details = MockClientCallDetails()
70+
request = "request"
71+
72+
res = enricher.intercept_unary_unary(continuation, details, request)
73+
74+
assert res == "response"
75+
continuation.assert_called_once_with(details, request)
76+
mock_span_non_recording.set_attribute.assert_not_called()
77+
extractor.assert_not_called()
78+
79+
80+
@pytest.mark.parametrize(
81+
"static_attrs,request_val,extractor_return,expected_attrs",
82+
[
83+
# Case 1: Only static attributes
84+
({"static.key": "static.val"}, "req", None, {"static.key": "static.val"}),
85+
# Case 2: Only dynamic attributes
86+
(None, "req", {"dynamic.key": "dynamic.val"}, {"dynamic.key": "dynamic.val"}),
87+
# Case 3: Both static and dynamic
88+
(
89+
{"static.key": "static.val"},
90+
"req",
91+
{"dynamic.key": "dynamic.val"},
92+
{"static.key": "static.val", "dynamic.key": "dynamic.val"},
93+
),
94+
# Case 4: Dynamic extractor returns None values (should be skipped)
95+
(
96+
{"static.key": "static.val"},
97+
"req",
98+
{"dynamic.key": None, "other.key": "other.val"},
99+
{"static.key": "static.val", "other.key": "other.val"},
100+
),
101+
],
102+
)
103+
def test_enricher_recording_span(
104+
mock_span, static_attrs, request_val, extractor_return, expected_attrs
105+
):
106+
"""Verifies static and dynamic attribute resolution on recording spans."""
107+
from google.api_core.observability.tracing import OtelSpanEnricher
108+
109+
if extractor_return is not None:
110+
extractor = Mock(return_value=extractor_return)
111+
else:
112+
extractor = None
113+
114+
enricher = OtelSpanEnricher(
115+
static_attributes=static_attrs, attribute_extractor=extractor
116+
)
117+
118+
continuation = Mock(return_value="response")
119+
details = MockClientCallDetails()
120+
request = request_val
121+
122+
res = enricher.intercept_unary_unary(continuation, details, request)
123+
124+
assert res == "response"
125+
continuation.assert_called_once_with(details, request)
126+
127+
# Check that expected attributes were set
128+
for key, val in expected_attrs.items():
129+
mock_span.set_attribute.assert_any_call(key, val)
130+
131+
# Total set_attribute calls should match expected_attrs size
132+
assert mock_span.set_attribute.call_count == len(expected_attrs)
133+
134+
if extractor:
135+
extractor.assert_called_once_with(request, details)
136+
137+
138+
def test_enricher_extractor_exception(mock_span):
139+
"""Verifies that exceptions in attribute extraction are caught and do not fail the call."""
140+
from google.api_core.observability.tracing import OtelSpanEnricher
141+
142+
def bad_extractor(req, details):
143+
raise ValueError("Extraction failure")
144+
145+
enricher = OtelSpanEnricher(
146+
static_attributes={"static.key": "static.val"},
147+
attribute_extractor=bad_extractor,
148+
)
149+
150+
continuation = Mock(return_value="response")
151+
details = MockClientCallDetails()
152+
request = "req"
153+
154+
res = enricher.intercept_unary_unary(continuation, details, request)
155+
156+
assert res == "response"
157+
continuation.assert_called_once_with(details, request)
158+
159+
# Static attributes should still be set before extractor failure
160+
mock_span.set_attribute.assert_called_once_with("static.key", "static.val")

0 commit comments

Comments
 (0)