Repository navigation
feat(observability): implement universal 4-path OpenTelemetry tracing - #18433
chalmerlowe wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements OpenTelemetry tracing capabilities across gRPC and REST transports for generated clients. It introduces _observability modules, updates transport constructors to accept client_options, and wires tracing interceptors into the transport layers. The reviewer identified a critical issue in the _shared_macros.j2 template where start_http_span was called with incorrect arguments, which would cause a runtime TypeError. The suggested fix involves passing a properly constructed request object to the tracing utility.
| with _observability.start_http_span( | ||
| client_options, | ||
| method=method, | ||
| url=url, | ||
| url_template=uri, | ||
| headers=headers, | ||
| body=body, | ||
| ) as span: |
There was a problem hiding this comment.
The current call to _observability.start_http_span passes client_options as the first positional argument (which maps to request in the function signature) and passes method, url, headers, and body as keyword arguments. However, start_http_span does not accept these keyword arguments and expects a request object with those attributes. This will result in a TypeError at runtime when tracing is enabled.
To fix this, dynamically construct a lightweight request object using Python's built-in type() constructor and pass it as the request argument, while correctly passing url_template and client_options as keyword arguments.
with _observability.start_http_span(
type("Request", (), {"method": method, "url": url, "headers": headers, "body": body})(),
url_template=uri,
client_options=client_options,
) as span:
| "DeleteOperation", | ||
| request_type="operations_pb2.DeleteOperationRequest", | ||
| response_type="None", | ||
| rpc_name="google.longrunning.Operations/DeleteOperation", |
There was a problem hiding this comment.
Note
As context for the reviewer:
For native methods (like Echo or GetSecret), the generator reads the service's .proto file directly, so constructing the name in the template is straightforward:
method_name="{{ '.'.join(method.meta.address.package) }}.{{ service.name }}/{{ method.name }}"However, mixins don't live in the service’s proto. Mixin methods (GetOperation, GetIamPolicy, ListLocations) are synthetic—they are injected by the generator from the static catalog in gapic/schema/mixins.py
Without a name attribute, any mixin call (like polling an operation or checking IAM permissions) would be unable to start an OpenTelemetry method span, or would emit an unknown/nameless span that failed our contract checks.
|
|
||
| import abc | ||
| import inspect | ||
| from typing import {% if service.any_extended_operations_methods %}Any, {% endif %}Awaitable, Callable, Dict, Optional, Sequence, Union |
There was a problem hiding this comment.
The focus for this file is to get fundamental/core elements into the Base class to:
- Help eliminate some checks that we were originally considering placing in the Transport classes, etc.
- Cut down on some of the boilerplate in the Transport classes
| } | ||
| {% endmacro %} | ||
|
|
||
| {# TODO: This helper logic to check whether `kind` needs to be configured in wrap_method |
There was a problem hiding this comment.
Comment for Reviewers:
This logic got moved to the base transport.
daniel-sanche
left a comment
There was a problem hiding this comment.
There's a lot I haven't looked at yet, but wanted to get some of my first comments out
| return self._wrap(gapic_v1.method.wrap_method, _WRAP_METHOD_SUPPORTS_TRACING, func, *args, **kwargs) | ||
|
|
||
| def _wrap_async_method(self, func, *args, **kwargs): | ||
| return self._wrap(gapic_v1.method_async.wrap_method, _ASYNC_WRAP_METHOD_SUPPORTS_TRACING, func, *args, **kwargs) |
There was a problem hiding this comment.
I'm finding all these wrappers very hard to follow. Is there any way we can filter these out instead of wrapping, like I suggested in the other PR?
If we do add extra helpers, I don't think we should need these three, along with additional _prep_wrapped_messages and _wrap_method implementations in the async transport. Can we try to simplify the wrapping logic?
There was a problem hiding this comment.
EXPLANATION & RESOLVED
There were originally seven functions involved in wrapping transport methods. We cut that to four:
BaseTransport._wrap_method(sync wrapper inbase.py.j2)BaseTransport._wrap_async_method(async wrapper inbase.py.j2)- REMOVED:
BaseTransport._wrap(internal dispatcher inbase.py.j2)
-> We inlined this directly into_wrap_methodand_wrap_async_methodto remove this layer of indirection. The downside is that we are duplicating about 17 lines of code. - REMOVED:
GrpcAsyncIOTransport._wrap_method(override ingrpc_asyncio.py.j2) - REMOVED:
RestAsyncIOTransport._wrap_method(override inrest_asyncio.py.j2)
-> These overrides were an awkward detour. The async macro in_shared_macros.j2now calls_wrap_async_methoddirectly on the transport.
The remaining four methods serve clear, distinct purposes and cannot be eliminated. These first two are pre-existing dict initializers that map RPC callables for client dispatch:
BaseTransport._prep_wrapped_messages(precomputes sync method dict inbase.py.j2)- Async Transports'
_prep_wrapped_messages(precomputes async method dict in_shared_macros.j2)
These two act as the transports's local binding adaptors:
BaseTransport._wrap_methodBaseTransport._wrap_async_method:
They:- Shield against version skew: Check
_WRAP_METHOD_SUPPORTS_TRACINGto strip tracing keyword args (client_options,kind,method_name,is_streaming) when running against oldergoogle-api-coreinstallations that do not accept them. - Inject instance state: Automatically pass
self._client_optionsandself.kindtogapic_v1.method.wrap_method/gapic_v1.method_async.wrap_methodacross all RPCs. - Keep
gapic_v1.method.wrap_methoda pure static utility ingoogle-api-core, completely decoupled from transport instance internals.
- Shield against version skew: Check
There was a problem hiding this comment.
There is still severe code duplication between prep_wrappd_messages sync/async, and _wrap_method/wrap_method_async. And it seems surprising that in some places, async code is in shared_macros, and sometimes it is baked into the base class. I think there should be a way to generalize this a bit more, so logic doesn't need to be copied in multiple locations
This isn't really related to your observability changes though, so we don't have to spend too much time on this now. But if you want, I can try to suggest a patch for this
| return span_name, span_attributes, resolved_headers | ||
|
|
||
|
|
||
| class trace_http_request: |
There was a problem hiding this comment.
RESOLVED
Good questions/good ideas.
- We stripped out the attribute processing code and put it in a separate function:
_build_http_span_attributes - We merged the old
start_http_spanandtrace_http_requestfunctions into a single class with context management__entry__and__exit__points calledtrace_http_request.
NOTE: we strayed from custom and named the class using snake_case rather than PascalCase in alignment the principle that most context managers are more aligned with verbs/action versus nouns/objects and to mirror other context managers in the standard library:
contextlib.redirect_stdoutunittest.mock.patchurllib.request.urlopen
are classes named in snake_case.
There was a problem hiding this comment.
nit: For debugging purposes, I'd still suggest giving the class a PascalCase name, but have it accessed trough a snake_case method:
def trace_http_request(*args, **kwargs):
return _TraceManager(*args, **kwargs)
class _TraceContext:
...
| def host(self): | ||
| return self._host | ||
|
|
||
| def _wrap_method(self, func, *args, **kwargs): |
There was a problem hiding this comment.
@ohmayr
Following up on our discussion:
We created _wrap_method in base.py primarily because google-api-core and the generated client libraries are released independently.
-
Preventing
TypeErroron older environments:
If newly generated client code passed our new tracing arguments (client_options,kind,method_name) directly intowrap_method, any user running the library with an older version ofgoogle-api-corewould immediately hitTypeError: wrap_method() got an unexpected keyword argument. We can’t fix that insidewrap_methodbecause older versions ofgoogle-api-coreare already deployed in the wild. -
Serving as a runtime bridge:
Sitting inside the generated transport,_wrap_methodchecks once at module import whether the installedgoogle-api-coreunderstands tracing. If it does, it passes the tracing configuration through; if it doesn’t, it cleanly strips those new kwargs so existing RPC calls continue working without error. -
Keeping generated code DRY:
The transport instance already knows its own_client_options(tracer providers) andkind("grpc","rest", etc.). Having_wrap_methodbind those automatically means the code generator doesn't have to repeat that boilerplate across every single RPC in the service's method table.
| # The fallback below strips tracing-specific arguments when an older version | ||
| # of google-api-core is installed (which does not accept client_options, etc.). | ||
| for k in ["client_options", "method_name", "is_streaming", "kind"]: | ||
| kwargs.pop(k, None) |
There was a problem hiding this comment.
It looks like this change puts kind behind the _WRAP_METHOD_SUPPORTS_TRACING flag. But isn't kind already in use? Wouldn't stripping it here cause issues for async rest?
There was a problem hiding this comment.
I agree with this. If we pop kind like this, we lose the logic where we call wrap_errors for grpc (by checking kind) which will result in a bug.
There was a problem hiding this comment.
RESOLVED:
So this got complicated quick.
Turns out, since we support google-api-core all the way back to 2.14-ish...
2.14 to 2.19-ish no support for kind OR any tracing arguments.
In 2.19-ish Omair added support for kind to distinguish between gRPC and REST and prevent REST callables from being wrapped with gRPC callables, but still no support for tracing.
Now, in 2.36 we are introducing client_options, method_name, is_streaming AND continuing the original use of kind.
So we have three different scenarios to support when trying to pass args back to google-api-core and avoid a TypeError.
This now adapts to any of those situations.
There was a problem hiding this comment.
NOTE: @daniel-sanche @ohmayr
I would like to move all of this into _compat and just have a three line stub here in Transport to call _compat._wrap_method OR _compat._wrap_async_method.
That will cut down on a lot of duplicate boilerplate and seems like the right place to deal with these version-specific intricacies.
I don't really wanna do that in this PR. I would rather focus as much as we can on bringing this PR to a close and doing a fast follow OR stripping this out of this PR and doing it separately.
I created an issue to track the overhaul and deduplication of all things wrap.
| return self._wrap(gapic_v1.method.wrap_method, _WRAP_METHOD_SUPPORTS_TRACING, func, *args, **kwargs) | ||
|
|
||
| def _wrap_async_method(self, func, *args, **kwargs): | ||
| return self._wrap(gapic_v1.method_async.wrap_method, _ASYNC_WRAP_METHOD_SUPPORTS_TRACING, func, *args, **kwargs) |
There was a problem hiding this comment.
There is still severe code duplication between prep_wrappd_messages sync/async, and _wrap_method/wrap_method_async. And it seems surprising that in some places, async code is in shared_macros, and sometimes it is baked into the base class. I think there should be a way to generalize this a bit more, so logic doesn't need to be copied in multiple locations
This isn't really related to your observability changes though, so we don't have to spend too much time on this now. But if you want, I can try to suggest a patch for this
| return span_name, span_attributes, resolved_headers | ||
|
|
||
|
|
||
| class trace_http_request: |
There was a problem hiding this comment.
nit: For debugging purposes, I'd still suggest giving the class a PascalCase name, but have it accessed trough a snake_case method:
def trace_http_request(*args, **kwargs):
return _TraceManager(*args, **kwargs)
class _TraceContext:
...
|
@daniel-sanche I concur that we can prolly do more to reduce some duplication, but I believe some of this is in main. I don't wanna lose focus on getting this PR merged, so let's defer this action, I have created an Issue to track this.
|
| self._start_span_fn = None | ||
| if ( | ||
| not is_streaming | ||
| and kind == "grpc" | ||
| and kind in self._SUPPORTED_TRACING_KINDS |
There was a problem hiding this comment.
Note
Comment for reviewers:
@ohmayr asked a question in another venue that was something like this "do we need to check these? the values are coming from the GAPIC layer so aren't they already constrained?"
wrap_method and method_async.wrap_method are public APIs in google-api-core, exposed to handwritten clients, custom transports, and test mocks (not just generator code).
Public API Contract & Guardrails:
Because callers can pass arbitrary inputs to kind (or inherit BaseTransport.kind == ""), we need an explicit whitelist of supported tracing kinds. Without it, an arbitrary or mock transport (kind="mock", kind="custom") would fall through and blindly emit OpenTelemetry spans with "rpc.system.name" set to "grpc", violating semantic conventions and generating dirty telemetry.
Controlled Scope:
We only want method tracing to initialize when we know the transport's wire semantics and how to map its attributes properly. Whitelisting supported kinds acts as a fail-safe boundary.
Clean Polymorphism:
Because _AsyncGapicCallable inherits from _GapicCallable, leveraging _SUPPORTED_TRACING_KINDS across the classes allows us to enforce this contract gracefully—letting sync allow ("grpc", "rest") and async allow ("grpc_asyncio", "rest_asyncio") using standard object-oriented design without duplicating the initialization or span-creation logic.
| _WRAP_METHOD_SUPPORTS_KIND = ( | ||
| "kind" in inspect.signature(gapic_v1.method.wrap_method).parameters | ||
| ) | ||
| _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( | ||
| "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters | ||
| ) | ||
| _ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( | ||
| "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters |
There was a problem hiding this comment.
have we considered the performance impact of calling inspect.signature?
There was a problem hiding this comment.
RESOLVED
@ohmayr
Good catch! Calling inspect.signature 4 times per service module at import time added unnecessary import latency across generated libraries and forced import inspect into transport modules.
We eliminated inspect.signature completely:
- Capability checks have also been moved into
_compat.py:
WRAP_METHOD_SUPPORTS_TRACINGis detected via an
O(1) attribute check (hasattr(_observability, "trace_http_request")), unified with the import oftrace_http_request.ASYNC_WRAP_METHOD_SUPPORTS_KINDchecksWRAP_METHOD_SUPPORTS_TRACINGorhasattr(gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND")
- Transport
base.pyimports these boolean constants directly from._compat. - Added comments in
_compat.pydocumenting the minimumgoogle-api-coreversions (>= 2.36.0 and >= 2.29.0) required before these checks can be retired.
daniel-sanche
left a comment
There was a problem hiding this comment.
Looking close, but a few more comments
| span_name, | ||
| kind=trace.SpanKind.CLIENT, | ||
| attributes=span_attributes, | ||
| ) |
There was a problem hiding this comment.
Should we be setting record_exception=False and set_status_on_exception=False here?
I see we're doing some manual error recording in the __exit__ method here, so do we still need the build-in error handling?
| response = {{ await_prefix }}getattr(session, method)( | ||
| "{host}{uri}".format(host=host, uri=uri), | ||
| timeout=timeout, | ||
| url = "{host}{uri}".format(host=host, uri=uri) |
There was a problem hiding this comment.
This is supposed to be a url_template (i.e., arguments replaces with placeholders). But it looks like this might be the full expanded URL?
|
|
||
| # Temporarily set the ambient global tracer provider | ||
| original_provider = trace.get_tracer_provider() | ||
| trace.set_tracer_provider(global_provider) |
There was a problem hiding this comment.
Can we mokey-patch this? It seems like this modifies some global state, which can only be set once per process. I could see this causing hard-to-catch issues in the future
| apply_interceptors = getattr( | ||
| grpc_helpers, | ||
| "apply_channel_interceptors", | ||
| lambda channel, interceptors: channel, |
There was a problem hiding this comment.
Could it be a problem that this does nothing on old versions of api_core?
Maybe we should add an implementation to _compat, like async. Or at least mention in the docstring that it requires a certain version of api_core. Or am I overthinking this?
4aee356 to
0566e94
Compare
0566e94 to
4711333
Compare
…error recording, and templates - Hoist sync channel interceptors (apply_channel_interceptors) and async channel interceptors (apply_async_channel_interceptors) to _compat with active fallback wrapping, eliminating the noop lambda fallback in grpc.py.j2 - Hoist wrap_method tracing and kind support constants to _compat and eliminate inspect.signature module-load checks in base transports - Adopt hybrid error recording in _TraceContext.record_error to enrich span attributes (error.type, status.message) while allowing upstream OpenTelemetry defaults to handle exception events - Pass parameterized proto route template from http_options into trace_http_request url_template - Add rest_transport.kind assertion for AsyncResumableUploadServiceRestTransport to maintain 100% test coverage - Add unit test isolation for custom tracer provider and test cases for _compat interceptors
4711333 to
1ffdedb
Compare
feat(observability): implement universal 4-path OpenTelemetry tracing
Problems Solved
Google Cloud Python client libraries support four communication paths: synchronous gRPC, asynchronous gRPC, synchronous REST (HTTP), and asynchronous REST (HTTP). Previously, distributed OpenTelemetry tracing was only wired for synchronous gRPC calls, leaving asynchronous and HTTP communications untraced. Additionally, earlier drafts of asynchronous tracing attempted to modify gRPC channels after creation, which violated the immutability rules of the underlying Python gRPC library, and did not consistently forward client options across all transport classes.
Solutions
This pull request provides a unified, cross-transport tracing implementation:
Universal 4-Transport Support:
GrpcTransport): Continues using OpenTelemetry gRPC channel interceptors.GrpcAsyncIOTransport): Supplies OpenTelemetry interceptors directly during channel creation, respecting the immutable design of asynchronous gRPC channels.RestTransport): Adds wire span tracking around HTTP requests with automatic W3C trace context header injection (traceparent).AsyncRestTransport): Integrates HTTP wire span tracking and async context lifecycle handling with W3C header propagation.Refined Transport Contracts & Cleanup:
BaseTransport.kindto safely return an empty string by default instead of raising an exception.asyncio.CancelledError) so spans are closed accurately when asynchronous tasks are cancelled.Notes for Reviewers
packages/gapic-generatorand core helper functions inpackages/google-api-core.