Skip to content

Commit 558f8fd

Browse files
committed
feat(core): add request hook to inject GCP resource and project attributes
1 parent bc94977 commit 558f8fd

2 files changed

Lines changed: 112 additions & 8 deletions

File tree

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ def is_otel_capabilities_enabled(
6060
return False
6161

6262

63+
def _extract_t4_attributes(request: Any) -> dict[str, Any]:
64+
"""Extracts Google Cloud semantic and resource attributes from a gRPC request object.
65+
66+
Args:
67+
request: The gRPC request object.
68+
69+
Returns:
70+
dict[str, Any]: A dictionary of semantic attributes.
71+
"""
72+
attrs: dict[str, Any] = {}
73+
if request is None:
74+
return attrs
75+
76+
name = getattr(request, "name", None)
77+
if name and isinstance(name, str):
78+
attrs["gcp.resource.name"] = name
79+
if "projects/" in name:
80+
parts = name.split("/")
81+
try:
82+
idx = parts.index("projects")
83+
if idx + 1 < len(parts):
84+
attrs["gcp.project_id"] = parts[idx + 1]
85+
except ValueError:
86+
pass
87+
88+
parent = getattr(request, "parent", None)
89+
if parent and isinstance(parent, str):
90+
attrs["gcp.resource.parent"] = parent
91+
if "gcp.project_id" not in attrs and "projects/" in parent:
92+
parts = parent.split("/")
93+
try:
94+
idx = parts.index("projects")
95+
if idx + 1 < len(parts):
96+
attrs["gcp.project_id"] = parts[idx + 1]
97+
except ValueError:
98+
pass
99+
100+
return attrs
101+
102+
103+
def _client_request_hook(span: Any, request: Any) -> None:
104+
"""OpenTelemetry client request hook to inject GCP resource attributes into the span."""
105+
if span is None or not getattr(span, "is_recording", lambda: True)():
106+
return
107+
attrs = _extract_t4_attributes(request)
108+
for key, value in attrs.items():
109+
span.set_attribute(key, value)
110+
111+
63112
def _get_otel_interceptor(
64113
client_options: ClientOptions | dict[str, Any] | None = None,
65114
is_async: bool = False,
@@ -83,8 +132,14 @@ def _get_otel_interceptor(
83132
tracer_provider = getattr(client_options, _TRACER_PROVIDER, None)
84133

85134
if is_async:
86-
return otel_grpc.aio_client_interceptors(tracer_provider=tracer_provider)
87-
return otel_grpc.client_interceptor(tracer_provider=tracer_provider)
135+
return otel_grpc.aio_client_interceptors(
136+
tracer_provider=tracer_provider,
137+
request_hook=_client_request_hook,
138+
)
139+
return otel_grpc.client_interceptor(
140+
tracer_provider=tracer_provider,
141+
request_hook=_client_request_hook,
142+
)
88143

89144

90145
def get_otel_interceptor(

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

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ def test_get_otel_interceptor_sync_default(monkeypatch):
103103

104104
result = _observability._get_otel_interceptor()
105105
assert result is mock_interceptor
106-
mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None)
106+
mock_otel_grpc.client_interceptor.assert_called_once_with(
107+
tracer_provider=None,
108+
request_hook=_observability._client_request_hook,
109+
)
107110

108111

109112
def test_get_otel_interceptor_sync_config(monkeypatch):
@@ -126,7 +129,8 @@ def test_get_otel_interceptor_sync_config(monkeypatch):
126129
result = _observability._get_otel_interceptor(client_options=options)
127130
assert result is mock_interceptor
128131
mock_otel_grpc.client_interceptor.assert_called_once_with(
129-
tracer_provider=mock_tracer_provider
132+
tracer_provider=mock_tracer_provider,
133+
request_hook=_observability._client_request_hook,
130134
)
131135

132136

@@ -150,7 +154,8 @@ def test_get_otel_interceptor_sync_dict_config(monkeypatch):
150154
result = _observability._get_otel_interceptor(client_options=options)
151155
assert result is mock_interceptor
152156
mock_otel_grpc.client_interceptor.assert_called_once_with(
153-
tracer_provider=mock_tracer_provider
157+
tracer_provider=mock_tracer_provider,
158+
request_hook=_observability._client_request_hook,
154159
)
155160

156161

@@ -174,7 +179,8 @@ def test_get_otel_interceptor_async(monkeypatch):
174179
result = _observability._get_otel_interceptor(client_options=options, is_async=True)
175180
assert result is mock_async_interceptors
176181
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
177-
tracer_provider=mock_tracer_provider
182+
tracer_provider=mock_tracer_provider,
183+
request_hook=_observability._client_request_hook,
178184
)
179185

180186

@@ -216,7 +222,8 @@ def test_get_otel_interceptor_enabled(monkeypatch):
216222
assert callable(interceptor)
217223

218224
mock_otel_grpc.client_interceptor.assert_called_once_with(
219-
tracer_provider=mock_tracer_provider
225+
tracer_provider=mock_tracer_provider,
226+
request_hook=_observability._client_request_hook,
220227
)
221228

222229
result = interceptor(mock_raw_channel)
@@ -298,5 +305,47 @@ def test_get_otel_async_interceptor_enabled(monkeypatch):
298305
result = _observability.get_otel_async_interceptor(client_options=options)
299306
assert result is mock_async_interceptors
300307
mock_otel_grpc.aio_client_interceptors.assert_called_once_with(
301-
tracer_provider=mock_tracer_provider
308+
tracer_provider=mock_tracer_provider,
309+
request_hook=_observability._client_request_hook,
310+
)
311+
312+
313+
def test_extract_t4_attributes():
314+
"""Proves that _extract_t4_attributes correctly extracts GCP resource name,
315+
parent, and project ID from gRPC request objects.
316+
"""
317+
assert _observability._extract_t4_attributes(None) == {}
318+
319+
# With name
320+
req_name = mock.Mock(spec=["name"], name="req_name")
321+
req_name.name = "projects/my-project/secrets/my-secret"
322+
attrs = _observability._extract_t4_attributes(req_name)
323+
assert attrs["gcp.resource.name"] == "projects/my-project/secrets/my-secret"
324+
assert attrs["gcp.project_id"] == "my-project"
325+
326+
# With parent
327+
req_parent = mock.Mock(spec=["parent"], name="req_parent")
328+
req_parent.parent = "projects/parent-project"
329+
attrs = _observability._extract_t4_attributes(req_parent)
330+
assert attrs["gcp.resource.parent"] == "projects/parent-project"
331+
assert attrs["gcp.project_id"] == "parent-project"
332+
333+
334+
def test_client_request_hook():
335+
"""Proves that _client_request_hook attaches extracted T4 attributes to recording spans."""
336+
# Non-recording span should not set attributes
337+
mock_span_non_rec = mock.Mock()
338+
mock_span_non_rec.is_recording.return_value = False
339+
_observability._client_request_hook(mock_span_non_rec, mock.Mock())
340+
mock_span_non_rec.set_attribute.assert_not_called()
341+
342+
# Recording span should set attributes
343+
mock_span_rec = mock.Mock()
344+
mock_span_rec.is_recording.return_value = True
345+
req = mock.Mock(name="req")
346+
req.name = "projects/my-proj/secrets/s1"
347+
_observability._client_request_hook(mock_span_rec, req)
348+
mock_span_rec.set_attribute.assert_any_call(
349+
"gcp.resource.name", "projects/my-proj/secrets/s1"
302350
)
351+
mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj")

0 commit comments

Comments
 (0)