1313# limitations under the License.
1414
1515import sys
16+ import types
1617from unittest import mock
1718
1819import 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
281339def 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