Skip to content

feat(api-core): add channel orchestration for OpenTelemetry (B) - #18237

Merged
chalmerlowe merged 7 commits into
feat/otel-tracing-centralized-interceptorfrom
feat/otel-tracing-eager-channel-wrapping
Sep 1, 2026
Merged

feat(api-core): add channel orchestration for OpenTelemetry (B)#18237
chalmerlowe merged 7 commits into
feat/otel-tracing-centralized-interceptorfrom
feat/otel-tracing-eager-channel-wrapping

Conversation

@chalmerlowe

@chalmerlowe chalmerlowe commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces OpenTelemetry helper functions in google.api_core._observability to produce channel wrappers for synchronous gRPC channels and interceptors for asynchronous gRPC channels.

Problem

Generated client libraries need a consistent and maintainable way to instrument gRPC channels with OpenTelemetry tracing when enabled via environment variables or client options.
Because synchronous gRPC channels can be wrapped post-creation while asynchronous gRPC channels require interceptors at channel creation time, client transports need helpers that return the appropriate channel wrapper or async interceptors without duplicating OpenTelemetry resolution logic across client libraries.

Solution

This pull request introduces the following helper functions in google.api_core._observability:

  1. get_otel_channel_wrapper(client_options):
    • Returns a channel-wrapping function (Callable[[Channel], Channel]) for synchronous gRPC channels when OpenTelemetry tracing is enabled and installed.
    • Integrates with grpc_helpers.apply_channel_wrappers to wrap raw channels using OpenTelemetry's intercept_channel.
  2. get_otel_async_interceptor(client_options):
    • Returns a list of OpenTelemetry asynchronous client interceptors (aio_client_interceptors) for use when constructing grpc.aio channels.
  3. _get_otel_interceptor(client_options, is_async):
    • Internal helper that extracts tracer_provider from ClientOptions and creates the appropriate OpenTelemetry sync or async interceptors.

Testing

  • Added unit tests in tests/unit/test_observability.py covering:
    • Sync and async interceptor extraction and tracer_provider configuration.
    • get_otel_channel_wrapper behavior when tracing is disabled, when OpenTelemetry is not installed, and when tracing is enabled.
    • Integration between get_otel_channel_wrapper and grpc_helpers.apply_channel_wrappers.
    • get_otel_async_interceptor behavior across disabled, missing, and enabled states.

Notes for Reviewers

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors OpenTelemetry channel instrumentation in google/api_core/_observability.py by replacing apply_otel_capabilities_to_channel with dedicated helpers for creating synchronous and asynchronous channels with OTel capabilities (create_channel_with_otel and create_async_channel_with_otel). Unit tests are updated accordingly. The review feedback highlights an inconsistency in interceptor execution order between the sync and async implementations, suggesting that the async OTel interceptor should be prepended rather than appended to the interceptors list to maintain consistent tracing semantics across both environments.

Comment thread packages/google-api-core/google/api_core/_observability.py Outdated
Comment thread packages/google-api-core/tests/unit/test_observability.py Outdated
mock_channel = mock.Mock()
mock_intercepted_channel = mock.Mock()
def test_get_otel_interceptor_sync_default(monkeypatch):
mock_otel = mock.Mock()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is plenty of room for deduplicating some of the inner workings of these tests (using fixtures, reusable functions, etc). Happy to revise these but would prefer to get some initial buy-in on the overall approach in the body of the code before investing in what might end up being premature optimization.

@chalmerlowe
chalmerlowe marked this pull request as ready for review August 27, 2026 18:00
@chalmerlowe
chalmerlowe requested a review from a team as a code owner August 27, 2026 18:00
def create_channel_with_otel(
channel_factory: Callable[..., Any],
client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None,
**channel_kwargs: Any,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also accept *channel_args? That would make this easier to pass into the transport init: #18188 (comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding *channel_args to create_channel_with_otel (and create_async_channel_with_otel) is a good improvement.

Transports pass self._host positionally to channel_init(self._host, ...). Supporting *channel_args allows us to pass functools.partial(_observability.create_channel_with_otel, Transport.create_channel, client_options=self._client_options) as the channel argument in the client.

This preserves true lazy channel initialization in the transport and eliminates the need for the client to duplicate extracting and passing credentials, scopes, quota_project_id, etc.

return otel_grpc.client_interceptor(tracer_provider=tracer_provider)


def create_channel_with_otel(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments in your other PR, but if it's possible to decouple the interceptor more from the channel, that could make thinks a lot easier for composition in the future.

I think the previous apply_otel_capabilities_to_channel would be better suited for this. If we go with option A, the client could do something like

grpc_interceptor = functools.partial(apply_otel_capabilities_to_channel, client_options=options)
interceptor_list = [grpc_interceptor, logging_interceptor]
Transport(interceptors=interceptor_list, ...)

@chalmerlowe chalmerlowe changed the title feat(api-core): add eager channel orchestration for OpenTelemetry feat(api-core): add channel orchestration for OpenTelemetry Aug 28, 2026
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-eager-channel-wrapping branch from 1a04d27 to cf95523 Compare August 28, 2026 13:56
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-centralized-interceptor branch from 394451b to 8b2dc1f Compare August 31, 2026 12:35
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-eager-channel-wrapping branch from cf95523 to 9d8d542 Compare August 31, 2026 13:34
@chalmerlowe chalmerlowe changed the title feat(api-core): add channel orchestration for OpenTelemetry feat(api-core): add channel orchestration for OpenTelemetry (B) Aug 31, 2026
- Implement create_channel_with_otel and create_async_channel_with_otel helpers

- Deduplicate interceptor instantiation via internal _get_otel_interceptor

- Add unit tests in test_observability.py
… tests

- Use list(channel_kwargs.pop('interceptors', None) or []) in create_async_channel_with_otel

- Add unit tests for None and omitted interceptors arguments
…ion in channel factories

- Add *channel_args to create_channel_with_otel and create_async_channel_with_otel

- Make client_options keyword-only to prevent argument collision with functools.partial

- Add TDD unit tests with detailed docstrings for positional forwarding and partial binding
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-eager-channel-wrapping branch from d751865 to 92c18b6 Compare September 1, 2026 11:28
@chalmerlowe
chalmerlowe merged commit 3687533 into feat/otel-tracing-centralized-interceptor Sep 1, 2026
13 checks passed
@chalmerlowe
chalmerlowe deleted the feat/otel-tracing-eager-channel-wrapping branch September 1, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants