feat(core): add request hook to enrich T4 network spans with GCP resource attributes (E) - #18272
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces OpenTelemetry request hooks to extract and inject Google Cloud semantic and resource attributes (such as resource name, parent, and project ID) from gRPC request objects into OpenTelemetry spans. Specifically, it adds _extract_t4_attributes and _client_request_hook helper functions in _observability.py, registers the hook in the gRPC client interceptors, and includes comprehensive unit tests to verify this behavior. There are no review comments, so I have no feedback to provide.
a7be0e4 to
9efe757
Compare
9efe757 to
40071ff
Compare
40071ff to
8c9d21a
Compare
8c9d21a to
558f8fd
Compare
558f8fd to
a6f3b3a
Compare
a6f3b3a to
7e1e498
Compare
7e1e498 to
131a80a
Compare
131a80a to
0da0216
Compare
0da0216 to
1c3834e
Compare
| try: | ||
| attrs["server.port"] = int(port_str) | ||
| except ValueError: | ||
| attrs["server.port"] = 443 |
There was a problem hiding this comment.
Note
QUESTION: is 443 a reasonable default OR should we default to "not given" or something similar?
There was a problem hiding this comment.
I think assuming 443 could be risky, especially since you're parsing http:// endpoints above
Do you know how Java is managing this? This may be a question for Blake
There was a problem hiding this comment.
I asked Jetski, and it said Java may be omitting it if it can't parse one. And it and pointed out that the Otel spec says "If using a port other than the default port for this service."
| ) | ||
|
|
||
|
|
||
| def test_extract_endpoint_attributes(): |
There was a problem hiding this comment.
This test (test_extract_endpoint_attributes) is functional, but I don't like it.
If we agree on the approach in this PR, I am happy to revisit these tests to make them more concise, parametrized, etc.
… hook - Add rpc.system.name: 'grpc' - Extract server.address and server.port from client options endpoint - Extract gcp.grpc.resend_count from request resend count - Extract gcp.resource.destination.id from request name or parent - Add _client_response_hook for status code, error.type, and status.message - Plumb response_hook into get_otel_interceptor and get_otel_async_interceptor
- Test endpoint attribute parsing across host/port variations - Test destination id and resend count extraction - Test client request and response hooks covering all status and error cases - Test interceptor creation and custom endpoint attribute propagation - Achieve 100% statement and branch coverage on _observability.py
…and hooks - Rename _extract_t4_attributes to _extract_grpc_request_attributes - Rename _make_client_request_hook to _make_grpc_client_request_hook - Rename _client_request_hook to _grpc_client_request_hook - Rename _client_response_hook to _grpc_client_response_hook - Preserve generic _extract_endpoint_attributes for shared transport usage
…ntion - Rename test_extract_t4_attributes to test_extract_grpc_request_attributes - Rename test_client_request_hook to test_grpc_client_request_hook - Rename test_client_response_hook to test_grpc_client_response_hook - Update interceptor hook references to _grpc_client_* hooks
de053b3 to
051b3d7
Compare
| 14: "UNAVAILABLE", | ||
| 15: "DATA_LOSS", | ||
| 16: "UNAUTHENTICATED", | ||
| } |
There was a problem hiding this comment.
Can we use the standard grpc.StatusCode enum throughout this file? We shouldn't need to manage these ourselves
Is this because grpc is an optional dependency?
| _grpc_client_request_hook = _make_grpc_client_request_hook() | ||
|
|
||
|
|
||
| def _grpc_client_response_hook(span: Any, response: Any) -> None: |
There was a problem hiding this comment.
Have you been able to test this with a client?
I was curious about the typing here, so I tried to look it up, but I'm pretty confused. Gemini is telling me that the response is a protobuf message, and this is only called on successful requests. And it found this open issue, saying it may be passing details for async requests. I'm having a hard time finding official docs around this. Are you sure it can complish what we need?
| try: | ||
| attrs["server.port"] = int(port_str) | ||
| except ValueError: | ||
| attrs["server.port"] = 443 |
There was a problem hiding this comment.
I think assuming 443 could be risky, especially since you're parsing http:// endpoints above
Do you know how Java is managing this? This may be a question for Blake
| endpoint = getattr(client_options, "api_endpoint", None) | ||
|
|
||
| if endpoint and isinstance(endpoint, str): | ||
| clean = endpoint.replace("http://", "").replace("https://", "").strip("/") |
There was a problem hiding this comment.
Is there a reason you're avoiding something like urllib.parse to parse this for us?
Manual parsing can be brittle. E.g.m what if this is a ipv6 address? Or there's a path component after the port?
| attrs["gcp.grpc.resend_count"] = resend_count | ||
|
|
||
| name = getattr(request, "name", None) | ||
| if isinstance(name, str) and name: |
There was a problem hiding this comment.
When would you expect this to be non-string? Are you sure we should fall back to the parent in that case?
| else: | ||
| parent = getattr(request, "parent", None) | ||
| if isinstance(parent, str) and parent: | ||
| attrs["gcp.resource.destination.id"] = parent |
There was a problem hiding this comment.
nit: we could reduce some duplication here:
resource_id = getattr(request, "name", None) or getattr(request, "parent", None)
if isinstance(resource_id, str) and resource_id:
attrs["gcp.resource.destination.id"] = resource_id
| _make_grpc_client_request_hook(endpoint_attrs) | ||
| if endpoint_attrs | ||
| else _grpc_client_request_hook | ||
| ) |
There was a problem hiding this comment.
nit: can't this just be _make_grpc_client_request_hook(endpoint_attrs)? It seems like the implementation already handles empty endpoint_attrs, so I'm not sure we need to handle both cases here
| try: | ||
| attrs["server.port"] = int(port_str) | ||
| except ValueError: | ||
| attrs["server.port"] = 443 |
There was a problem hiding this comment.
I asked Jetski, and it said Java may be omitting it if it can't parse one. And it and pointed out that the Otel spec says "If using a port other than the default port for this service."
| return False | ||
|
|
||
|
|
||
| _STATUS_CODE_NAMES = { |
There was a problem hiding this comment.
In the PR description, you say " Without request- and response-level enrichment, spans cannot identify ... normalized string status codes".
Can you expand on that? Does the default instrumenter add code numbers, but not strings? It feels strange that we would have to add the cost of an extra callback layer to do that kind of transformation
Could we add this somewhere else in the stack? Or just stick with error numbers instead of names?
daniel-sanche
left a comment
There was a problem hiding this comment.
My main comment is around the response callback. Are you sure it can do what we need? And do we really need a callback for status names?
Problem
Low-level gRPC transport spans generated by
opentelemetry-instrumentation-grpccapture standard RPC metadata but lack Google Cloud semantic context and resource identity. Without request- and response-level enrichment, spans cannot identify the target GCP resource, normalized string status codes (rpc.response.status_code), or configured endpoint attributes (server.address,server.port).Solution
This PR enriches wire-level gRPC client spans in
google-api-coreby attaching request and response hooks to both synchronous and asynchronous OpenTelemetry gRPC interceptors:Request Hook (
_grpc_client_request_hook):gcp.grpc.resend_countwhen present on retryable requests.rpc.system.name: "grpc"per OpenTelemetry semantic conventions.server.addressandserver.portfromclient_options.api_endpoint.gcp.resource.destination.idfromrequest.nameorrequest.parentfollowing standard Google Cloud resource naming conventions.Response Hook (
_grpc_client_response_hook):"OK","NOT_FOUND","UNAVAILABLE") onrpc.response.status_code.error.typeandstatus.messagefrom response details.Transport Integration:
get_otel_interceptor(sync) andget_otel_async_interceptor(async) withingoogle.api_core._observability.Notes for Reviewers