Repository navigation
feat(observability): suppress redundant gRPC auto-instrumentation spans - #18592
chalmerlowe wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces client interceptors (_SuppressingClientInterceptor and _AsyncSuppressingClientInterceptor) to suppress redundant downstream generic auto-instrumentation spans when OpenTelemetry is enabled, ensuring that only the feature-rich Google Cloud SDK spans are emitted. It also updates the interceptor pipeline logic to prepend OpenTelemetry interceptors in order. The review feedback suggests critical performance optimizations, specifically moving imports out of the hot path in _suppress_instrumentation to avoid overhead on every RPC call, and removing a redundant inline import of grpc in otel_interceptor.
| assert child_span.attributes.get("server.address") == "localhost" | ||
| assert child_span.attributes.get("server.port") == 7469 | ||
| assert child_span.attributes.get("url.domain") == "googleapis.com" | ||
| assert child_span.attributes.get("rpc.response.status_code") == "OK" |
There was a problem hiding this comment.
A good deal of the above is duplicative with test_auto_instrumentation_suppression_async()
It also happens to be duplicative with a number of the other existing tests.
I am putting together a PR with two standardized assertion functions to help reduce the duplication across the broader array of tests, but did not want to clutter up this PR.
daniel-sanche
left a comment
There was a problem hiding this comment.
I don't have time to do a full in-depth review today, but I'd really suggest taking a step back and seeing if there's another way to solve this. This would add a lot of bloat, with the three layers of wrapers fighting each other, which I'm sure would show up in benchmarks. And the workarounds added to apply_channel_interceptors to identify and re-order interceptors feel like a big abstraction overreach
I added a comment about a possible approach to strip out unwanted interceptors before applying our custom otel ones. If that doesn't work, maybe spend some time considering other options, before committing to these new wrapper layers
| channel = grpc.intercept_channel(channel, suppressor) | ||
| except (AttributeError, TypeError): # pragma: NO COVER | ||
| # Fallback if grpc lacks intercept_channel or custom channel type rejects it. | ||
| pass |
There was a problem hiding this comment.
My unerstanding of this fix is that channel may already be wrapped with an interceptor, and we need to add another wrapper to supress it
Instead, can we unwrap the channel here?
def otel_interceptor(channel: grpc.Channel) -> grpc.Channel:
# Peel off any generic upstream auto-instrumentation wrapper once at setup time.
while (
isinstance(getattr(channel, "_interceptor", None), OpenTelemetryClientInterceptor)
and not getattr(channel._interceptor, "_is_otel_interceptor", False)
and hasattr(channel, "_channel")
):
channel = channel._channel
return otel_grpc.intercept_channel(channel, interceptor)
(We could do something similar on the async side, pruning existing the channel's interceptor list before adding our own otel one)
There was a problem hiding this comment.
RECOMMENDATION: LEAVE AS IS
We investigated unwrapping/peeling the channel at setup time as you suggested, but found two significant risks that make runtime suppression the safer path:
-
Dropping Intentionally Configured User Interceptors: Upstream
opentelemetry-instrumentation-grpcprovides a public factory functionclient_interceptor(request_hook=..., filter_=...)that developers use to manually configure custom request hooks or filters without subclassing. The returned object is a direct instance ofOpenTelemetryClientInterceptor. If a user configures a channel with custom hooks and passes it into our client, peeling offOpenTelemetryClientInterceptorat setup time will silently delete the customer's custom hooks. In contrast,suppress_instrumentation()is the upstream contract designed to suppress span creation without mutating/deleting parts of the channel structure. -
Shared Channel Breakage: If a customer passes an existing channel and shares it between our SDK and their own internal gRPC services, stripping/mutating interceptors on the channel permanently breaks tracing for their internal services. Runtime suppression avoids this because
suppress_instrumentation()is thread/task-local and only active during the execution of OUR SDK's call.
| # OpenTelemetry interceptors and downstream suppressors are inserted at the | ||
| # beginning of the channel's interceptor pipeline in order, so that our | ||
| # Google SDK spans wrap outside and downstream generic auto-instrumentation | ||
| # (e.g. GrpcAioInstrumentorClient) is suppressed. Other interceptors are appended. |
There was a problem hiding this comment.
I would really like to avoid this kind of special-case re-ordering if at all possible. Even using a different method for re-ordering, before calling apply_channel_interceptors if you have to
If you need to add it here, it should be called out in the docstring that otel is treated as a special case
There was a problem hiding this comment.
For clarity, the reordering is required to enable the suppression.
We looked for ways to make it simpler and more robust and to help divide the architecture such that the calling function decides whether to prepend OR append the interceptor.
- In
google-api-core(grpc_helpers_async.py): We stripped all_is_otel_interceptorlogic and index math out ofapply_channel_interceptors. It is now a generic utility that acceptsprepend: bool = False. When prepending, it uses Python slice assignment (target_list[:0] = matching) so the batch is inserted at the front in exact relative order without using stateful index counters (also resolving Tony's sequential ordering comment). - In
gapic-generator(grpc_asyncio.py.j2): The transport caller explicitly declares its ordering intent. Normal user and logging interceptors are applied withprepend=False(appended). Observability interceptors are applied withprepend=True, placing them at the front of the channel's execution pipeline so they wrap outside and suppress ambient auto-instrumentation on pre-existing channels.
This keepsgoogle-api-coreagnostic of OpenTelemetry while giving calling functions control over pipeline positioning.
chalmerlowe
left a comment
There was a problem hiding this comment.
Some suggested changes.
Thanks for the feedback, @daniel-sanche We recommend moving forward with the approach we recommended in the PR. Additional clarification can be found on specific comments, but broadly:
Beyond that:
|
No region tags are edited in this PR.This comment is generated by snippet-bot.
|
2a358f3 to
6b941c8
Compare
…sync tests - Update grpc_asyncio.py.j2 comments to concise descriptions and regenerate goldens - Document gRPC onion prepending rationale and label symmetric dispatch sections in _observability.py - Consolidate and parametrize async observability unit tests with explicit parameter IDs - Add mock_async_channel fixture to conftest.py and maintain 100% test coverage
Intent & Context
When upstream OpenTelemetry gRPC auto-instrumentation (
opentelemetry.instrumentation.grpc.GrpcInstrumentorClient().instrument()) is enabled globally in a customer application alongside Google Cloud SDK tracing, downstream channel invocations create redundant, un-enriched wire attempt spans.This PR introduces targeted downstream suppression interceptors for both sync (
grpc) and async (grpc.aio) transports:suppress_instrumentationcontext manager.feat/otel-tracing-universal-4path).