From 131a80aa7365e6372330b9a0d86e0acd2fe63dfc Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 3 Sep 2026 06:30:13 -0400 Subject: [PATCH] feat(core): add request hook to inject GCP resource and project attributes --- .../google/api_core/_observability.py | 55 ++++++++++++++++++- .../tests/unit/test_observability.py | 47 +++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index f101cec28f5c..262b9beda34d 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -64,6 +64,55 @@ def is_otel_capabilities_enabled( return False +def _extract_t4_attributes(request: Any) -> dict[str, Any]: + """Extracts Google Cloud semantic and resource attributes from a gRPC request object. + + Args: + request: The gRPC request object. + + Returns: + dict[str, Any]: A dictionary of semantic attributes. + """ + attrs: dict[str, Any] = {} + if request is None: + return attrs + + name = getattr(request, "name", None) + if name and isinstance(name, str): + attrs["gcp.resource.name"] = name + if "projects/" in name: + parts = name.split("/") + try: + idx = parts.index("projects") + if idx + 1 < len(parts): + attrs["gcp.project_id"] = parts[idx + 1] + except ValueError: + pass + + parent = getattr(request, "parent", None) + if parent and isinstance(parent, str): + attrs["gcp.resource.parent"] = parent + if "gcp.project_id" not in attrs and "projects/" in parent: + parts = parent.split("/") + try: + idx = parts.index("projects") + if idx + 1 < len(parts): + attrs["gcp.project_id"] = parts[idx + 1] + except ValueError: + pass + + return attrs + + +def _client_request_hook(span: Any, request: Any) -> None: + """OpenTelemetry client request hook to inject GCP resource attributes into the span.""" + if span is None or not getattr(span, "is_recording", lambda: True)(): + return + attrs = _extract_t4_attributes(request) + for key, value in attrs.items(): + span.set_attribute(key, value) + + def _get_tracer_provider( client_options: ClientOptions | dict[str, Any] | None = None, ) -> opentelemetry.trace.TracerProvider | None: @@ -102,7 +151,8 @@ def get_otel_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] interceptor: ClientInterceptor = otel_grpc.client_interceptor( - tracer_provider=_get_tracer_provider(client_options) + tracer_provider=_get_tracer_provider(client_options), + request_hook=_client_request_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -131,5 +181,6 @@ def get_otel_async_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] return otel_grpc.aio_client_interceptors( - tracer_provider=_get_tracer_provider(client_options) + tracer_provider=_get_tracer_provider(client_options), + request_hook=_client_request_hook, ) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 8e8964e66264..a512ea068981 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -162,7 +162,8 @@ def test_get_otel_interceptor_enabled(monkeypatch): assert callable(interceptor) mock_otel_grpc.client_interceptor.assert_called_once_with( - tracer_provider=mock_tracer_provider + tracer_provider=mock_tracer_provider, + request_hook=_observability._client_request_hook, ) result = interceptor(mock_raw_channel) @@ -251,5 +252,47 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): result = _observability.get_otel_async_interceptor(client_options=options) assert result is mock_async_interceptors mock_otel_grpc.aio_client_interceptors.assert_called_once_with( - tracer_provider=mock_tracer_provider + tracer_provider=mock_tracer_provider, + request_hook=_observability._client_request_hook, ) + + +def test_extract_t4_attributes(): + """Proves that _extract_t4_attributes correctly extracts GCP resource name, + parent, and project ID from gRPC request objects. + """ + assert _observability._extract_t4_attributes(None) == {} + + # With name + req_name = mock.Mock(spec=["name"], name="req_name") + req_name.name = "projects/my-project/secrets/my-secret" + attrs = _observability._extract_t4_attributes(req_name) + assert attrs["gcp.resource.name"] == "projects/my-project/secrets/my-secret" + assert attrs["gcp.project_id"] == "my-project" + + # With parent + req_parent = mock.Mock(spec=["parent"], name="req_parent") + req_parent.parent = "projects/parent-project" + attrs = _observability._extract_t4_attributes(req_parent) + assert attrs["gcp.resource.parent"] == "projects/parent-project" + assert attrs["gcp.project_id"] == "parent-project" + + +def test_client_request_hook(): + """Proves that _client_request_hook attaches extracted T4 attributes to recording spans.""" + # Non-recording span should not set attributes + mock_span_non_rec = mock.Mock() + mock_span_non_rec.is_recording.return_value = False + _observability._client_request_hook(mock_span_non_rec, mock.Mock()) + mock_span_non_rec.set_attribute.assert_not_called() + + # Recording span should set attributes + mock_span_rec = mock.Mock() + mock_span_rec.is_recording.return_value = True + req = mock.Mock(name="req") + req.name = "projects/my-proj/secrets/s1" + _observability._client_request_hook(mock_span_rec, req) + mock_span_rec.set_attribute.assert_any_call( + "gcp.resource.name", "projects/my-proj/secrets/s1" + ) + mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj")