Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions packages/google-api-core/google/api_core/_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
47 changes: 45 additions & 2 deletions packages/google-api-core/tests/unit/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Loading