Skip to content

Commit 0da0216

Browse files
authored
feat(core): add request hook to inject GCP resource and project attributes
1 parent 62a8261 commit 0da0216

2 files changed

Lines changed: 98 additions & 4 deletions

File tree

packages/google-api-core/google/api_core/_observability.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,55 @@ def is_otel_capabilities_enabled(
6464
return False
6565

6666

67+
def _extract_t4_attributes(request: Any) -> dict[str, Any]:
68+
"""Extracts Google Cloud semantic and resource attributes from a gRPC request object.
69+
70+
Args:
71+
request: The gRPC request object.
72+
73+
Returns:
74+
dict[str, Any]: A dictionary of semantic attributes.
75+
"""
76+
attrs: dict[str, Any] = {}
77+
if request is None:
78+
return attrs
79+
80+
name = getattr(request, "name", None)
81+
if name and isinstance(name, str):
82+
attrs["gcp.resource.name"] = name
83+
if "projects/" in name:
84+
parts = name.split("/")
85+
try:
86+
idx = parts.index("projects")
87+
if idx + 1 < len(parts):
88+
attrs["gcp.project_id"] = parts[idx + 1]
89+
except ValueError:
90+
pass
91+
92+
parent = getattr(request, "parent", None)
93+
if parent and isinstance(parent, str):
94+
attrs["gcp.resource.parent"] = parent
95+
if "gcp.project_id" not in attrs and "projects/" in parent:
96+
parts = parent.split("/")
97+
try:
98+
idx = parts.index("projects")
99+
if idx + 1 < len(parts):
100+
attrs["gcp.project_id"] = parts[idx + 1]
101+
except ValueError:
102+
pass
103+
104+
return attrs
105+
106+
107+
def _client_request_hook(span: Any, request: Any) -> None:
108+
"""OpenTelemetry client request hook to inject GCP resource attributes into the span."""
109+
if span is None or not getattr(span, "is_recording", lambda: True)():
110+
return
111+
attrs = _extract_t4_attributes(request)
112+
for key, value in attrs.items():
113+
span.set_attribute(key, value)
114+
115+
67116
def _get_tracer_provider(
68117
client_options: ClientOptions | dict[str, Any] | None = None,
69118
) -> opentelemetry.trace.TracerProvider | None:
@@ -102,7 +151,8 @@ def get_otel_interceptor(
102151
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
103152

104153
interceptor: ClientInterceptor = otel_grpc.client_interceptor(
105-
tracer_provider=_get_tracer_provider(client_options)
154+
tracer_provider=_get_tracer_provider(client_options),
155+
request_hook=_client_request_hook,
106156
)
107157

108158
def otel_interceptor(channel: grpc.Channel) -> grpc.Channel:
@@ -131,5 +181,6 @@ def get_otel_async_interceptor(
131181
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
132182

133183
return otel_grpc.aio_client_interceptors(
134-
tracer_provider=_get_tracer_provider(client_options)
184+
tracer_provider=_get_tracer_provider(client_options),
185+
request_hook=_client_request_hook,
135186
)

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ def test_get_otel_interceptor_enabled(monkeypatch):
162162
assert callable(interceptor)
163163

164164
mock_otel_grpc.client_interceptor.assert_called_once_with(
165-
tracer_provider=mock_tracer_provider
165+
tracer_provider=mock_tracer_provider,
166+
request_hook=_observability._client_request_hook,
166167
)
167168

168169
result = interceptor(mock_raw_channel)
@@ -251,5 +252,47 @@ def test_get_otel_async_interceptor_enabled(monkeypatch):
251252
result = _observability.get_otel_async_interceptor(client_options=options)
252253
assert result is mock_async_interceptors
253254
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
254-
tracer_provider=mock_tracer_provider
255+
tracer_provider=mock_tracer_provider,
256+
request_hook=_observability._client_request_hook,
255257
)
258+
259+
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"
272+
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"
279+
280+
281+
def test_client_request_hook():
282+
"""Proves that _client_request_hook attaches extracted T4 attributes to recording spans."""
283+
# Non-recording span should not set attributes
284+
mock_span_non_rec = mock.Mock()
285+
mock_span_non_rec.is_recording.return_value = False
286+
_observability._client_request_hook(mock_span_non_rec, mock.Mock())
287+
mock_span_non_rec.set_attribute.assert_not_called()
288+
289+
# Recording span should set attributes
290+
mock_span_rec = mock.Mock()
291+
mock_span_rec.is_recording.return_value = True
292+
req = mock.Mock(name="req")
293+
req.name = "projects/my-proj/secrets/s1"
294+
_observability._client_request_hook(mock_span_rec, req)
295+
mock_span_rec.set_attribute.assert_any_call(
296+
"gcp.resource.name", "projects/my-proj/secrets/s1"
297+
)
298+
mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj")

0 commit comments

Comments
 (0)