Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
45ef7ac
feat(api-core): add tracer_provider to ClientOptions for OTel support
chalmerlowe Aug 18, 2026
5a84a8f
feat(api-core): add _otel_helpers to centralize OTel interceptor reso…
chalmerlowe Aug 18, 2026
343e4ca
feat(api-core): support dictionaries in _otel_helpers
chalmerlowe Aug 18, 2026
9fa7af9
Removed pytest.
chalmerlowe Aug 18, 2026
5f8f97d
fix(api-core): safely resolve tracer_provider from client_options avo…
chalmerlowe Aug 21, 2026
ef05d0e
feat(api-core): replace get_otel_grpc_interceptor with eager channel …
chalmerlowe Aug 24, 2026
85d4784
style(api-core): remove decorative emoji from comment
chalmerlowe Aug 24, 2026
7a894d8
style(api-core): format code with ruff and fix flake8
chalmerlowe Aug 24, 2026
ed18bac
test(api-core): add coverage for dict config in _otel_helpers
chalmerlowe Aug 24, 2026
0f0e6e7
refactor(api-core): remove redundant checks from apply_otel_capabilit…
chalmerlowe Aug 24, 2026
bac7f7d
elaborate in a comment on apply_* role and usage.
chalmerlowe Aug 24, 2026
f9caa92
refactor(api-core): rename helpers to _observability.py and improve docs
chalmerlowe Aug 25, 2026
8caf428
chore(api-core): upgrade tracer_provider type hints to string references
chalmerlowe Aug 25, 2026
7c8e58a
chore: add opentelemetry to mypy ignores
chalmerlowe Aug 25, 2026
053fde9
chore: add explanatory comment to mypy opentelemetry ignore
chalmerlowe Aug 25, 2026
f96333a
chore(api-core): add opentelemetry dependencies and constraints
chalmerlowe Aug 25, 2026
5fdd203
Removes one line of docstring to more closely align with performance.
chalmerlowe Aug 26, 2026
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
5 changes: 5 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ ignore_missing_imports = True
ignore_missing_imports = True


# OpenTelemetry is an optional dependency and may not be installed in all test
# environments (e.g. to verify core functionality works without it).
[mypy-opentelemetry.*]
ignore_missing_imports = True

# ==============================================================================
# PACKAGE-SPECIFIC OVERRIDES & EXCEPTIONS
# ==============================================================================
Expand Down
89 changes: 89 additions & 0 deletions packages/google-api-core/google/api_core/_observability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""OpenTelemetry helpers for resolving and instantiating interceptors."""
Comment thread
daniel-sanche marked this conversation as resolved.

from typing import Any, Optional

from google.api_core import _feature_gating_helpers
from google.api_core.client_options import ClientOptions

_TRACER_PROVIDER = "tracer_provider"


def is_otel_capabilities_enabled(
client_options: Optional[ClientOptions | dict[str, Any]] = None,
env_var: str = "GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
) -> bool:
"""Checks if OTel capabilities are enabled and installed.

Args:
client_options: The client options object or dictionary.
env_var: The environment variable to check for enablement.

Returns:
bool: True if enabled and installed, False otherwise.
"""
is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags(
env_var=env_var,
feature_key=_TRACER_PROVIDER,
configuration=client_options,
)

if is_tracing_enabled:
try:
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] # noqa: F401

return True
except ImportError:
pass

return False


def apply_otel_capabilities_to_channel(

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.

should we have an async version of this?

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.

@daniel-sanche
As per the design docs, async is not part of this first phase.
It will be a following phase of the project.

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 don't think we need to include async code now, but I do think we need to at least consider it in our designs, so it doesn't diverge too much.

IIRC, async channels can't be mutated, so we probably shouldn't build a system around this API. Maybe we should just build and return the interceptor to the client, and let it decide how to add it to the channel?

@chalmerlowe chalmerlowe Aug 25, 2026

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.

@daniel-sanche

It may be worth clarifying a couple of specific constraints we bumped into regarding how OpenTelemetry handles gRPC in Python.

No Mutation in Place: the otel_grpc.intercept_channel does not mutate the channel in place; it returns a new intercepted channel instance. This aligns with standard gRPC behavior (both sync and async), so we aren't relying on in-place mutation here.

The TypeError Trap: The main reason we can't just build and return the interceptor to be processed by standard transport pipelines is a specific quirk of the OTel Python SDK. OTel's gRPC interceptors use custom protocols (grpcext) under the hood and are not compatible with standard grpc.ClientInterceptor type checks. If we put an OTel interceptor in a standard list and pass it to standard grpc.intercept_channel, it will crash with a TypeError. NOTE: we originally intended to build and return the interceptor but ran into these errors and are now pivoting.

Why Eager Wrapping: We are forced to use OTel's specialized otel_grpc.intercept_channel wrapper function. By handling this "eager wrapping" in the Client, we avoid leaking OTel-specific application logic into the "dumb" generated Transport, keeping the core transport pipeline cleaner and less complicated.

Regarding Async Forward-Compatibility: When we tackle async in the next phase, we may have more flexibility. OTel's async interceptors (aio_client_interceptors) are designed to be compatible with standard grpc.aio.ClientInterceptor interfaces. This means they will likely be able to be passed directly to standard pipelines without hitting the same TypeError hurdles we see in sync. However, if we prefer architectural consistency across the codebase, the "eager wrapping" pattern (using OTel's async helpers) remains a viable option to keep OTel logic centralized in the Client. There is some discussion about this at this bookmark in our internal documentation.

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.

Interesting, I didn't catch that quirk about otel_grpc wrapping. I still have concerns about what this will look like for async, but yeah, we can deal with that when we get there

channel: Any,
client_options: Optional[ClientOptions | dict[str, Any]] = None,
) -> Any:
"""Applies OTel capabilities (like tracing) to the channel.

Precondition: This function assumes `is_otel_capabilities_enabled` has already
been called and returned `True`, i.e. in the Client. At this time
this function is not intended to be standalone.

Args:
channel: The raw gRPC channel to wrap.
client_options: The client options object or dictionary.

Returns:
Any: The intercepted channel.

Raises:
ImportError: If OpenTelemetry packages are not installed and this function
is called directly (bypassing the precondition).
"""
Comment thread
daniel-sanche marked this conversation as resolved.
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]

tracer_provider = None
if isinstance(client_options, dict):
tracer_provider = client_options.get(_TRACER_PROVIDER)
elif client_options is not None:
tracer_provider = getattr(client_options, _TRACER_PROVIDER, None)

interceptor = otel_grpc.client_interceptor(tracer_provider=tracer_provider)

# We use OTel's own compatible applier to avoid standard gRPC TypeError.
return otel_grpc.intercept_channel(channel, interceptor)
8 changes: 8 additions & 0 deletions packages/google-api-core/google/api_core/client_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ def get_client_cert():

"""

import typing
import warnings
from typing import Callable, Mapping, Optional, Sequence, Tuple

if typing.TYPE_CHECKING:
import opentelemetry.trace

from google.api_core import general_helpers


Expand Down Expand Up @@ -98,6 +102,8 @@ class ClientOptions(object):
`googleapis.com`. If both `api_endpoint` and `universe_domain` are set,
then `api_endpoint` is used as the service endpoint. If `api_endpoint` is
not specified, the format will be `{service}.{universe_domain}`.
tracer_provider (Optional["opentelemetry.trace.TracerProvider"]): The OpenTelemetry tracer provider to use
for tracing in supported libraries.

Raises:
ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source``
Expand All @@ -117,6 +123,7 @@ def __init__(
api_key: Optional[str] = None,
api_audience: Optional[str] = None,
universe_domain: Optional[str] = None,
tracer_provider: Optional["opentelemetry.trace.TracerProvider"] = None,
):
if credentials_file is not None:
warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning)
Expand All @@ -136,6 +143,7 @@ def __init__(
self.api_key = api_key
self.api_audience = api_audience
self.universe_domain = universe_domain
self.tracer_provider = tracer_provider

def __repr__(self) -> str:
return "ClientOptions: " + repr(self.__dict__)
Expand Down
7 changes: 7 additions & 0 deletions packages/google-api-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies = [
"proto-plus >= 1.26.1, < 2.0.0",
"google-auth >= 2.14.1, < 3.0.0",
"requests >= 2.33.0, < 3.0.0",
"opentelemetry-api >= 1.44.0, < 2.0.0",
]
dynamic = ["version"]

Expand All @@ -64,6 +65,12 @@ grpc = [
"grpcio-status >= 1.59.0, < 2.0.0",
"grpcio-status >= 1.75.1, < 2.0.0; python_version >= '3.14'",
]
tracing = [
"opentelemetry-instrumentation-grpc >= 0.65b0, < 1.0.0",
]
testing = [
"opentelemetry-sdk >= 1.44.0, < 2.0.0",
]


[tool.setuptools.dynamic]
Expand Down
3 changes: 3 additions & 0 deletions packages/google-api-core/testing/constraints-3.10.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ requests==2.33.0
grpcio==1.59.0
grpcio-status==1.59.0
proto-plus==1.26.1
opentelemetry-api==1.44.0
opentelemetry-instrumentation-grpc==0.65b0
opentelemetry-sdk==1.44.0
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ grpcio==1.59.0
grpcio-status==1.59.0
proto-plus==1.26.1
aiohttp==3.13.4
opentelemetry-api==1.44.0
opentelemetry-instrumentation-grpc==0.65b0
opentelemetry-sdk==1.44.0
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from re import match

import pytest

from google.api_core import client_options

from ..helpers import warn_deprecated_credentials_file
Expand All @@ -30,6 +29,7 @@ def get_client_encrypted_cert():


def test_constructor():
mock_tracer_provider = object()
with warn_deprecated_credentials_file():
options = client_options.ClientOptions(
api_endpoint="foo.googleapis.com",
Expand All @@ -42,6 +42,7 @@ def test_constructor():
],
api_audience="foo2.googleapis.com",
universe_domain="googleapis.com",
tracer_provider=mock_tracer_provider,
)

assert options.api_endpoint == "foo.googleapis.com"
Expand All @@ -54,6 +55,7 @@ def test_constructor():
]
assert options.api_audience == "foo2.googleapis.com"
assert options.universe_domain == "googleapis.com"
assert options.tracer_provider is mock_tracer_provider


def test_constructor_with_encrypted_cert_source():
Expand Down Expand Up @@ -162,6 +164,7 @@ def test_repr():
"scopes",
"api_key",
"api_audience",
"tracer_provider",
]
)
options = client_options.ClientOptions(api_endpoint="foo.googleapis.com")
Expand Down
149 changes: 149 additions & 0 deletions packages/google-api-core/tests/unit/test_observability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import sys
from unittest import mock

from google.api_core import _observability
from google.api_core.client_options import ClientOptions


def test_is_otel_capabilities_enabled_disabled(monkeypatch):
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false")
assert not _observability.is_otel_capabilities_enabled()


def test_is_otel_capabilities_enabled_otel_missing(monkeypatch):
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")
# Simulate OTel not being installed by blocking imports
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None)

assert not _observability.is_otel_capabilities_enabled()


def test_is_otel_capabilities_enabled_otel_installed(monkeypatch):
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")

mock_otel = mock.Mock()
mock_otel_grpc = mock_otel.instrumentation.grpc

monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
)

assert _observability.is_otel_capabilities_enabled()


def test_apply_otel_capabilities_to_channel_enabled_otel_installed(monkeypatch):
mock_channel = 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.

NOTE TO REVIEWERS:

We are aware that there is significant duplication in these three tests.
We are looking for confirmation that the approach shown in the PR is acceptable.

With confirmation in hand we are happy to update these tests to simplify them, create mock fixtures, parametrize them where possible, but do not want to invest the time until we are confident that the approach will be approved.

Happy to consider updating the tests here OR in a fast follow PR.

mock_intercepted_channel = mock.Mock()

mock_otel = mock.Mock()
mock_otel_grpc = mock_otel.instrumentation.grpc
mock_interceptor = mock.Mock()

mock_otel_grpc.client_interceptor.return_value = mock_interceptor
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel

monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
)

result = _observability.apply_otel_capabilities_to_channel(mock_channel)

assert result is mock_intercepted_channel
mock_otel_grpc.client_interceptor.assert_called_once_with(tracer_provider=None)
mock_otel_grpc.intercept_channel.assert_called_once_with(
mock_channel, mock_interceptor
)


def test_apply_otel_capabilities_to_channel_enabled_via_config(monkeypatch):
# Tracing enabled via config (tracer_provider is set)
mock_tracer_provider = object()
options = ClientOptions(tracer_provider=mock_tracer_provider)

mock_channel = mock.Mock()
mock_intercepted_channel = mock.Mock()

mock_otel = mock.Mock()
mock_otel_grpc = mock_otel.instrumentation.grpc
mock_interceptor = mock.Mock()

mock_otel_grpc.client_interceptor.return_value = mock_interceptor
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel

monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
)

result = _observability.apply_otel_capabilities_to_channel(
mock_channel, client_options=options
)

assert result is mock_intercepted_channel
mock_otel_grpc.client_interceptor.assert_called_once_with(
tracer_provider=mock_tracer_provider
)
mock_otel_grpc.intercept_channel.assert_called_once_with(
mock_channel, mock_interceptor
)


def test_apply_otel_capabilities_to_channel_enabled_via_dict_config(monkeypatch):
# Tracing enabled via dict config
mock_tracer_provider = object()
options = {"tracer_provider": mock_tracer_provider}

mock_channel = mock.Mock()
mock_intercepted_channel = mock.Mock()

mock_otel = mock.Mock()
mock_otel_grpc = mock_otel.instrumentation.grpc
mock_interceptor = mock.Mock()

mock_otel_grpc.client_interceptor.return_value = mock_interceptor
mock_otel_grpc.intercept_channel.return_value = mock_intercepted_channel

monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
)
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
)

result = _observability.apply_otel_capabilities_to_channel(
mock_channel, client_options=options
)

assert result is mock_intercepted_channel
mock_otel_grpc.client_interceptor.assert_called_once_with(
tracer_provider=mock_tracer_provider
)
mock_otel_grpc.intercept_channel.assert_called_once_with(
mock_channel, mock_interceptor
)
Loading