Skip to content

Commit 230ffbf

Browse files
committed
test(core): add comprehensive unit tests for T4 gRPC telemetry and hooks
- Test endpoint attribute parsing across host/port variations - Test destination id and resend count extraction - Test client request and response hooks covering all status and error cases - Test interceptor creation and custom endpoint attribute propagation - Achieve 100% statement and branch coverage on _observability.py
1 parent e82001d commit 230ffbf

1 file changed

Lines changed: 256 additions & 23 deletions

File tree

‎packages/google-api-core/tests/unit/test_observability.py‎

Lines changed: 256 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import sys
16+
import types
1617
from unittest import mock
1718

1819
import pytest
@@ -164,6 +165,7 @@ def test_get_otel_interceptor_enabled(monkeypatch):
164165
mock_otel_grpc.client_interceptor.assert_called_once_with(
165166
tracer_provider=mock_tracer_provider,
166167
request_hook=_observability._client_request_hook,
168+
response_hook=_observability._client_response_hook,
167169
)
168170

169171
result = interceptor(mock_raw_channel)
@@ -254,28 +256,84 @@ def test_get_otel_async_interceptor_enabled(monkeypatch):
254256
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
255257
tracer_provider=mock_tracer_provider,
256258
request_hook=_observability._client_request_hook,
259+
response_hook=_observability._client_response_hook,
257260
)
258261

259262

260-
def test_extract_t4_attributes():
261-
"""Proves that _extract_t4_attributes correctly extracts GCP resource name,
262-
parent, and project ID from gRPC request objects.
263-
"""
264-
assert _observability._extract_t4_attributes(None) == {}
265-
266-
# With name
267-
req_name = mock.Mock(spec=["name"], name="req_name")
268-
req_name.name = "projects/my-project/secrets/my-secret"
269-
attrs = _observability._extract_t4_attributes(req_name)
270-
assert attrs["gcp.resource.name"] == "projects/my-project/secrets/my-secret"
271-
assert attrs["gcp.project_id"] == "my-project"
263+
def test_extract_endpoint_attributes():
264+
"""Proves that _extract_endpoint_attributes correctly parses server.address and server.port."""
265+
# None or empty options
266+
assert _observability._extract_endpoint_attributes(None) == {}
267+
assert _observability._extract_endpoint_attributes({}) == {}
268+
assert (
269+
_observability._extract_endpoint_attributes(ClientOptions(api_endpoint=None))
270+
== {}
271+
)
272272

273-
# With parent
274-
req_parent = mock.Mock(spec=["parent"], name="req_parent")
275-
req_parent.parent = "projects/parent-project"
276-
attrs = _observability._extract_t4_attributes(req_parent)
277-
assert attrs["gcp.resource.parent"] == "projects/parent-project"
278-
assert attrs["gcp.project_id"] == "parent-project"
273+
# Dict options with standard endpoint
274+
dict_opts = {"api_endpoint": "secretmanager.googleapis.com"}
275+
attrs = _observability._extract_endpoint_attributes(dict_opts)
276+
assert attrs["server.address"] == "secretmanager.googleapis.com"
277+
assert attrs["server.port"] == 443
278+
279+
# ClientOptions with custom port
280+
custom_opts = ClientOptions(api_endpoint="https://my-custom-host.com:8443/")
281+
attrs = _observability._extract_endpoint_attributes(custom_opts)
282+
assert attrs["server.address"] == "my-custom-host.com"
283+
assert attrs["server.port"] == 8443
284+
285+
# Invalid port string falls back to 443
286+
invalid_port_opts = ClientOptions(api_endpoint="my-custom-host.com:invalid_port")
287+
attrs = _observability._extract_endpoint_attributes(invalid_port_opts)
288+
assert attrs["server.address"] == "my-custom-host.com"
289+
assert attrs["server.port"] == 443
290+
291+
292+
@pytest.mark.parametrize(
293+
"req,expected_attrs",
294+
[
295+
(None, {"rpc.system.name": "grpc"}),
296+
(types.SimpleNamespace(), {"rpc.system.name": "grpc"}),
297+
(
298+
types.SimpleNamespace(name="projects/p1/secrets/s1"),
299+
{
300+
"rpc.system.name": "grpc",
301+
"gcp.resource.destination.id": "projects/p1/secrets/s1",
302+
},
303+
),
304+
(
305+
types.SimpleNamespace(parent="projects/parent-p1"),
306+
{
307+
"rpc.system.name": "grpc",
308+
"gcp.resource.destination.id": "projects/parent-p1",
309+
},
310+
),
311+
(
312+
types.SimpleNamespace(
313+
name="projects/p1/secrets/s1", parent="projects/parent-p1"
314+
),
315+
{
316+
"rpc.system.name": "grpc",
317+
"gcp.resource.destination.id": "projects/p1/secrets/s1",
318+
},
319+
),
320+
(
321+
types.SimpleNamespace(name="projects/p1/secrets/s1", resend_count=2),
322+
{
323+
"rpc.system.name": "grpc",
324+
"gcp.resource.destination.id": "projects/p1/secrets/s1",
325+
"gcp.grpc.resend_count": 2,
326+
},
327+
),
328+
(
329+
types.SimpleNamespace(resend_count=0),
330+
{"rpc.system.name": "grpc"},
331+
),
332+
],
333+
)
334+
def test_extract_t4_attributes(req, expected_attrs):
335+
"""Proves that _extract_t4_attributes extracts all T4 gRPC attributes."""
336+
assert _observability._extract_t4_attributes(req) == expected_attrs
279337

280338

281339
def test_client_request_hook():
@@ -286,13 +344,188 @@ def test_client_request_hook():
286344
_observability._client_request_hook(mock_span_non_rec, mock.Mock())
287345
mock_span_non_rec.set_attribute.assert_not_called()
288346

289-
# Recording span should set attributes
347+
# None span should safely return
348+
_observability._client_request_hook(None, mock.Mock())
349+
350+
# Recording span with default hook
290351
mock_span_rec = mock.Mock()
291352
mock_span_rec.is_recording.return_value = True
292-
req = mock.Mock(name="req")
293-
req.name = "projects/my-proj/secrets/s1"
353+
req = types.SimpleNamespace(name="projects/my-proj/secrets/s1", resend_count=1)
294354
_observability._client_request_hook(mock_span_rec, req)
355+
mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc")
295356
mock_span_rec.set_attribute.assert_any_call(
296-
"gcp.resource.name", "projects/my-proj/secrets/s1"
357+
"gcp.resource.destination.id", "projects/my-proj/secrets/s1"
358+
)
359+
mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1)
360+
361+
# Custom hook with endpoint attributes
362+
endpoint_hook = _observability._make_client_request_hook(
363+
{"server.address": "custom.api.com", "server.port": 443}
364+
)
365+
mock_span_custom = mock.Mock()
366+
mock_span_custom.is_recording.return_value = True
367+
endpoint_hook(mock_span_custom, req)
368+
mock_span_custom.set_attribute.assert_any_call("server.address", "custom.api.com")
369+
mock_span_custom.set_attribute.assert_any_call("server.port", 443)
370+
371+
372+
def test_client_response_hook():
373+
"""Proves that _client_response_hook sets rpc.response.status_code, error.type, and status.message."""
374+
# Non-recording span should not set attributes
375+
mock_span_non_rec = mock.Mock()
376+
mock_span_non_rec.is_recording.return_value = False
377+
_observability._client_response_hook(mock_span_non_rec, mock.Mock())
378+
mock_span_non_rec.set_attribute.assert_not_called()
379+
380+
# None span should safely return
381+
_observability._client_response_hook(None, mock.Mock())
382+
383+
# Response with no code method defaults to OK
384+
mock_span_ok = mock.Mock()
385+
mock_span_ok.is_recording.return_value = True
386+
_observability._client_response_hook(mock_span_ok, mock.Mock(spec=[]))
387+
mock_span_ok.set_attribute.assert_called_once_with("rpc.response.status_code", "OK")
388+
389+
# Response with StatusCode object having name (e.g. OK)
390+
mock_span_code_obj = mock.Mock()
391+
mock_span_code_obj.is_recording.return_value = True
392+
mock_resp_ok = mock.Mock()
393+
mock_code_ok = mock.Mock()
394+
mock_code_ok.name = "OK"
395+
mock_resp_ok.code.return_value = mock_code_ok
396+
_observability._client_response_hook(mock_span_code_obj, mock_resp_ok)
397+
mock_span_code_obj.set_attribute.assert_called_once_with(
398+
"rpc.response.status_code", "OK"
399+
)
400+
401+
# Response with error status (e.g. integer 14 -> UNAVAILABLE) and details
402+
mock_span_err = mock.Mock()
403+
mock_span_err.is_recording.return_value = True
404+
mock_resp_err = mock.Mock()
405+
mock_resp_err.code.return_value = 14
406+
mock_resp_err.details.return_value = "Service temporarily unavailable"
407+
_observability._client_response_hook(mock_span_err, mock_resp_err)
408+
mock_span_err.set_attribute.assert_any_call(
409+
"rpc.response.status_code", "UNAVAILABLE"
410+
)
411+
mock_span_err.set_attribute.assert_any_call("error.type", "UNAVAILABLE")
412+
mock_span_err.set_attribute.assert_any_call(
413+
"status.message", "Service temporarily unavailable"
414+
)
415+
416+
# Response where code() raises an exception is handled gracefully
417+
mock_span_exc = mock.Mock()
418+
mock_span_exc.is_recording.return_value = True
419+
mock_resp_exc = mock.Mock()
420+
mock_resp_exc.code.side_effect = RuntimeError("Broken call")
421+
_observability._client_response_hook(mock_span_exc, mock_resp_exc)
422+
mock_span_exc.set_attribute.assert_called_once_with(
423+
"rpc.response.status_code", "OK"
424+
)
425+
426+
# Response with error status but no details method
427+
mock_span_no_det = mock.Mock()
428+
mock_span_no_det.is_recording.return_value = True
429+
mock_resp_no_det = mock.Mock(spec=["code"])
430+
mock_resp_no_det.code.return_value = 14
431+
_observability._client_response_hook(mock_span_no_det, mock_resp_no_det)
432+
mock_span_no_det.set_attribute.assert_any_call(
433+
"rpc.response.status_code", "UNAVAILABLE"
434+
)
435+
mock_span_no_det.set_attribute.assert_any_call("error.type", "UNAVAILABLE")
436+
437+
# Response with error status where details() returns empty/None
438+
mock_span_empty_det = mock.Mock()
439+
mock_span_empty_det.is_recording.return_value = True
440+
mock_resp_empty_det = mock.Mock()
441+
mock_resp_empty_det.code.return_value = 14
442+
mock_resp_empty_det.details.return_value = ""
443+
_observability._client_response_hook(mock_span_empty_det, mock_resp_empty_det)
444+
mock_span_empty_det.set_attribute.assert_any_call(
445+
"rpc.response.status_code", "UNAVAILABLE"
446+
)
447+
448+
# Response with error status where details() raises an exception
449+
mock_span_exc_det = mock.Mock()
450+
mock_span_exc_det.is_recording.return_value = True
451+
mock_resp_exc_det = mock.Mock()
452+
mock_resp_exc_det.code.return_value = 14
453+
mock_resp_exc_det.details.side_effect = RuntimeError("Details broken")
454+
_observability._client_response_hook(mock_span_exc_det, mock_resp_exc_det)
455+
mock_span_exc_det.set_attribute.assert_any_call(
456+
"rpc.response.status_code", "UNAVAILABLE"
457+
)
458+
459+
460+
def test_extract_endpoint_attributes_empty_clean():
461+
"""Proves that endpoint consisting only of slashes/protocol results in empty attrs."""
462+
assert (
463+
_observability._extract_endpoint_attributes(
464+
ClientOptions(api_endpoint="http:///")
465+
)
466+
== {}
467+
)
468+
469+
470+
def test_get_otel_interceptor_with_api_endpoint(monkeypatch):
471+
"""Proves that get_otel_interceptor injects server.address and server.port when api_endpoint is set."""
472+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
473+
options = ClientOptions(api_endpoint="secretmanager.googleapis.com:443")
474+
475+
mock_otel = mock.Mock()
476+
mock_otel_grpc = mock_otel.instrumentation.grpc
477+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
478+
monkeypatch.setitem(
479+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
480+
)
481+
monkeypatch.setitem(
482+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
483+
)
484+
485+
interceptor = _observability.get_otel_interceptor(client_options=options)
486+
assert callable(interceptor)
487+
488+
# Verify custom request hook was passed
489+
args, kwargs = mock_otel_grpc.client_interceptor.call_args
490+
req_hook = kwargs["request_hook"]
491+
assert req_hook is not _observability._client_request_hook
492+
493+
# Test invoking the custom hook
494+
mock_span = mock.Mock()
495+
mock_span.is_recording.return_value = True
496+
req_hook(mock_span, None)
497+
mock_span.set_attribute.assert_any_call(
498+
"server.address", "secretmanager.googleapis.com"
499+
)
500+
mock_span.set_attribute.assert_any_call("server.port", 443)
501+
502+
503+
def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch):
504+
"""Proves that get_otel_async_interceptor injects server.address and server.port when api_endpoint is set."""
505+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
506+
options = ClientOptions(api_endpoint="secretmanager.googleapis.com:8443")
507+
508+
mock_otel = mock.Mock()
509+
mock_otel_grpc = mock_otel.instrumentation.grpc
510+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
511+
monkeypatch.setitem(
512+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
513+
)
514+
monkeypatch.setitem(
515+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
516+
)
517+
518+
result = _observability.get_otel_async_interceptor(client_options=options)
519+
assert result is not None
520+
521+
args, kwargs = mock_otel_grpc.aio_client_interceptors.call_args
522+
req_hook = kwargs["request_hook"]
523+
assert req_hook is not _observability._client_request_hook
524+
525+
mock_span = mock.Mock()
526+
mock_span.is_recording.return_value = True
527+
req_hook(mock_span, None)
528+
mock_span.set_attribute.assert_any_call(
529+
"server.address", "secretmanager.googleapis.com"
297530
)
298-
mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj")
531+
mock_span.set_attribute.assert_any_call("server.port", 8443)

0 commit comments

Comments
 (0)