Skip to content

Commit 40a2b2f

Browse files
committed
test(secretmanager): add OpenTelemetry observability unit tests with local in-memory gRPC server
1 parent 41f5983 commit 40a2b2f

2 files changed

Lines changed: 330 additions & 7 deletions

File tree

packages/google-cloud-secret-manager/noxfile.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
]
7373
UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = []
7474
UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [
75-
"../google-api-core[tracing,testing]",
75+
"../google-api-core",
7676
]
7777
UNIT_TEST_DEPENDENCIES: List[str] = []
7878
UNIT_TEST_EXTRAS: List[str] = []
@@ -266,19 +266,27 @@ def install_unittest_dependencies(session, *constraints):
266266

267267
@nox.session(python=ALL_PYTHON)
268268
@nox.parametrize(
269-
"protobuf_implementation",
270-
["python", "upb"],
269+
["protobuf_implementation", "install_otel"],
270+
[
271+
("python", True),
272+
("python", False),
273+
("upb", True),
274+
("upb", False),
275+
],
271276
)
272-
def unit(session, protobuf_implementation):
277+
def unit(session, protobuf_implementation, install_otel):
273278
# Install all test dependencies, then install this package in-place.
274279

275280
constraints_path = str(
276281
CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt"
277282
)
283+
if install_otel:
284+
session.install("../google-api-core[tracing,testing]")
285+
278286
install_unittest_dependencies(session, "-c", constraints_path)
279287

280288
# Run py.test against the unit tests.
281-
session.run(
289+
pytest_args = [
282290
"py.test",
283291
"--quiet",
284292
f"--junitxml=unit_{session.python}_sponge_log.xml",
@@ -288,8 +296,14 @@ def unit(session, protobuf_implementation):
288296
"--cov-config=.coveragerc",
289297
"--cov-report=",
290298
"--cov-fail-under=0",
291-
os.path.join("tests", "unit"),
292-
*session.posargs,
299+
]
300+
if not session.posargs:
301+
pytest_args.append(os.path.join("tests", "unit"))
302+
else:
303+
pytest_args.extend(session.posargs)
304+
305+
session.run(
306+
*pytest_args,
293307
env={
294308
"PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": protobuf_implementation,
295309
},
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
from concurrent import futures
18+
from unittest import mock
19+
20+
import grpc
21+
import pytest
22+
from google.api_core import _observability
23+
from google.api_core._feature_gating_helpers import FeatureGatingError
24+
from google.auth.credentials import AnonymousCredentials
25+
26+
from google.cloud.secretmanager_v1 import (
27+
SecretManagerServiceAsyncClient,
28+
SecretManagerServiceClient,
29+
)
30+
from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import (
31+
SecretManagerServiceGrpcTransport,
32+
)
33+
from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc_asyncio import (
34+
SecretManagerServiceGrpcAsyncIOTransport,
35+
)
36+
37+
try:
38+
from opentelemetry.sdk.trace import TracerProvider
39+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
40+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
41+
InMemorySpanExporter,
42+
)
43+
44+
OTEL_AVAILABLE = True
45+
except ImportError:
46+
OTEL_AVAILABLE = False
47+
48+
49+
class GenericHandler(grpc.GenericRpcHandler):
50+
"""A generic gRPC handler that catches all methods and returns empty bytes."""
51+
52+
def service(self, handler_call_details):
53+
return grpc.unary_unary_rpc_method_handler(
54+
lambda request, context: b"", # Return empty bytes
55+
request_deserializer=lambda x: x,
56+
response_serializer=lambda x: x,
57+
)
58+
59+
60+
@pytest.fixture(scope="module")
61+
def fake_grpc_server():
62+
"""Starts a local generic gRPC server on an open port."""
63+
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
64+
server.add_generic_rpc_handlers((GenericHandler(),))
65+
port = server.add_insecure_port("localhost:0")
66+
server.start()
67+
yield f"localhost:{port}"
68+
server.stop(None)
69+
70+
71+
@pytest.fixture
72+
def insecure_channel_patch(monkeypatch):
73+
"""Mocks grpc.secure_channel and grpc.aio.secure_channel to return insecure channels for local testing."""
74+
monkeypatch.setattr(
75+
grpc,
76+
"secure_channel",
77+
lambda target, *args, **kwargs: grpc.insecure_channel(target),
78+
)
79+
if hasattr(grpc, "aio"):
80+
monkeypatch.setattr(
81+
grpc.aio,
82+
"secure_channel",
83+
lambda target, *args, **kwargs: grpc.aio.insecure_channel(target),
84+
)
85+
86+
87+
@pytest.fixture
88+
def otel_in_memory():
89+
"""Sets up in-memory OTel exporting. Skips test if SDK is missing."""
90+
if not OTEL_AVAILABLE:
91+
pytest.skip("OpenTelemetry SDK not available")
92+
93+
exporter = InMemorySpanExporter()
94+
provider = TracerProvider()
95+
provider.add_span_processor(SimpleSpanProcessor(exporter))
96+
97+
return provider, exporter
98+
99+
100+
@pytest.mark.parametrize(
101+
"method_name, kwargs",
102+
[
103+
("list_secrets", {"parent": "projects/test-project"}),
104+
("get_secret", {"name": "projects/test-project/secrets/test-secret"}),
105+
],
106+
)
107+
def test_otel_tracing_enabled(
108+
fake_grpc_server,
109+
insecure_channel_patch,
110+
otel_in_memory,
111+
monkeypatch,
112+
method_name,
113+
kwargs,
114+
):
115+
"""Verify that calling API methods on SecretManagerServiceClient generates spans
116+
when OpenTelemetry tracing is enabled.
117+
"""
118+
provider, exporter = otel_in_memory
119+
120+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
121+
122+
client = SecretManagerServiceClient(
123+
transport="grpc",
124+
client_options={
125+
"api_endpoint": fake_grpc_server,
126+
"tracer_provider": provider,
127+
},
128+
credentials=AnonymousCredentials(),
129+
)
130+
131+
method = getattr(client, method_name)
132+
try:
133+
method(**kwargs)
134+
except Exception as e:
135+
# GenericHandler returns empty bytes b"", which proto3 deserializes to default message.
136+
print(f"Call raised: {e}")
137+
138+
spans = exporter.get_finished_spans()
139+
assert len(spans) > 0, "No spans recorded!"
140+
141+
span_names = [s.name for s in spans]
142+
assert any("SecretManagerService" in name for name in span_names)
143+
144+
# Validate standard OpenTelemetry gRPC span attributes
145+
span = spans[0]
146+
assert span.attributes.get("rpc.system") == "grpc"
147+
148+
149+
def test_otel_tracing_disabled(
150+
fake_grpc_server,
151+
insecure_channel_patch,
152+
otel_in_memory,
153+
monkeypatch,
154+
):
155+
"""Verify that no spans are generated when tracing is disabled."""
156+
provider, exporter = otel_in_memory
157+
158+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false")
159+
160+
from opentelemetry import trace
161+
162+
with mock.patch.object(trace, "get_tracer_provider", return_value=provider):
163+
client = SecretManagerServiceClient(
164+
transport="grpc",
165+
client_options={
166+
"api_endpoint": fake_grpc_server,
167+
},
168+
credentials=AnonymousCredentials(),
169+
)
170+
171+
try:
172+
client.list_secrets(parent="projects/test-project")
173+
except Exception:
174+
pass
175+
176+
spans = exporter.get_finished_spans()
177+
assert len(spans) == 0, (
178+
f"Spans were recorded but tracing should be disabled! Spans: {[s.name for s in spans]}"
179+
)
180+
181+
182+
def test_otel_tracing_feature_gating_error(
183+
fake_grpc_server,
184+
otel_in_memory,
185+
monkeypatch,
186+
):
187+
"""Verify that passing tracer_provider without the experimental environment variable
188+
raises FeatureGatingError (Fail Fast).
189+
"""
190+
provider, _ = otel_in_memory
191+
monkeypatch.delenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", raising=False)
192+
193+
with pytest.raises(
194+
FeatureGatingError,
195+
match="requires GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
196+
):
197+
SecretManagerServiceClient(
198+
transport="grpc",
199+
client_options={
200+
"api_endpoint": fake_grpc_server,
201+
"tracer_provider": provider,
202+
},
203+
credentials=AnonymousCredentials(),
204+
)
205+
206+
207+
def test_otel_tracing_custom_channel_with_wrappers(
208+
fake_grpc_server,
209+
otel_in_memory,
210+
monkeypatch,
211+
):
212+
"""Verify that when a custom channel is passed explicitly to the transport with wrappers,
213+
OpenTelemetry tracing wraps the custom channel and records spans.
214+
"""
215+
provider, exporter = otel_in_memory
216+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
217+
218+
otel_wrapper = _observability.get_otel_channel_wrapper(
219+
{"tracer_provider": provider}
220+
)
221+
assert otel_wrapper is not None
222+
223+
custom_channel = grpc.insecure_channel(fake_grpc_server)
224+
transport = SecretManagerServiceGrpcTransport(
225+
channel=custom_channel,
226+
wrappers=[otel_wrapper],
227+
)
228+
client = SecretManagerServiceClient(
229+
transport=transport,
230+
)
231+
232+
try:
233+
client.list_secrets(parent="projects/test-project")
234+
except Exception:
235+
pass
236+
237+
spans = exporter.get_finished_spans()
238+
assert len(spans) > 0, "No spans recorded on custom channel with OTel wrapper!"
239+
240+
241+
def test_custom_interceptors_and_wrappers_execution(
242+
fake_grpc_server,
243+
insecure_channel_patch,
244+
):
245+
"""Verify that SecretManagerServiceGrpcTransport executes both gRPC ClientInterceptor instances
246+
and Callable[[Channel], Channel] wrappers on real RPC calls to a local server.
247+
"""
248+
execution_order = []
249+
250+
class TrackingInterceptor(grpc.UnaryUnaryClientInterceptor):
251+
def intercept_unary_unary(self, continuation, client_call_details, request):
252+
execution_order.append("interceptor")
253+
return continuation(client_call_details, request)
254+
255+
def tracking_wrapper(ch: grpc.Channel) -> grpc.Channel:
256+
execution_order.append("wrapper")
257+
return ch
258+
259+
interceptor = TrackingInterceptor()
260+
transport = SecretManagerServiceGrpcTransport(
261+
host=fake_grpc_server,
262+
wrappers=[interceptor, tracking_wrapper],
263+
credentials=AnonymousCredentials(),
264+
)
265+
client = SecretManagerServiceClient(
266+
transport=transport,
267+
)
268+
269+
try:
270+
client.list_secrets(parent="projects/test-project")
271+
except Exception:
272+
pass
273+
274+
assert "wrapper" in execution_order
275+
assert "interceptor" in execution_order
276+
277+
278+
@pytest.mark.asyncio
279+
async def test_otel_tracing_async_client(
280+
fake_grpc_server,
281+
insecure_channel_patch,
282+
otel_in_memory,
283+
monkeypatch,
284+
):
285+
"""Verify that OpenTelemetry async client interceptors record spans on async calls."""
286+
provider, exporter = otel_in_memory
287+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
288+
289+
async_interceptors = _observability.get_otel_async_interceptor(
290+
{"tracer_provider": provider}
291+
)
292+
assert async_interceptors is not None
293+
294+
async_channel = grpc.aio.insecure_channel(
295+
fake_grpc_server,
296+
interceptors=async_interceptors,
297+
)
298+
transport = SecretManagerServiceGrpcAsyncIOTransport(channel=async_channel)
299+
client = SecretManagerServiceAsyncClient(
300+
transport=transport,
301+
)
302+
303+
try:
304+
await client.list_secrets(parent="projects/test-project")
305+
except Exception:
306+
pass
307+
308+
spans = exporter.get_finished_spans()
309+
assert len(spans) > 0, "No spans recorded on async client call!"

0 commit comments

Comments
 (0)