Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
1b60ad5
feat(secretmanager): inject OTel interceptor explicitly in gRPC trans…
chalmerlowe Aug 18, 2026
0b7c3c2
feat(secretmanager): refactor OTel interceptor injection to use centr…
chalmerlowe Aug 21, 2026
bedd92e
feat(otel): implement eager channel wrapping (Variant B) for OTel com…
chalmerlowe Aug 24, 2026
ddaca01
Updates comment.
chalmerlowe Aug 24, 2026
2013686
fix(secretmanager): align interceptor typehints and explain gRPC spec…
chalmerlowe Aug 24, 2026
4db7ed4
Add context to the comment
chalmerlowe Aug 24, 2026
1b042a2
refactor(secretmanager): align with new _observability helpers and sh…
chalmerlowe Aug 25, 2026
d825d9d
chore(secretmanager): parameterize unit session to include OTel testing
chalmerlowe Aug 25, 2026
151dc64
chore(secretmanager): use google-api-core extras for OTel testing in …
chalmerlowe Aug 25, 2026
dcc60bf
chore(secretmanager): run integration tests only on default Python ve…
chalmerlowe Aug 25, 2026
403c6bf
chore(secretmanager): restrict unit session to tests/unit
chalmerlowe Aug 25, 2026
5593437
update comment for clarity
chalmerlowe Aug 26, 2026
1e6691d
Update comment for clarity
chalmerlowe Aug 26, 2026
1cb9d2c
fixes spelling error
chalmerlowe Aug 26, 2026
6b28e5b
test(secretmanager): refactor otel channel injection unit tests with …
chalmerlowe Aug 26, 2026
a217227
chore(secretmanager): remove integration nox session
chalmerlowe Aug 26, 2026
a63cf47
test(secretmanager): add OpenTelemetry integration tests using local …
chalmerlowe Aug 26, 2026
ca7f1a9
Merge branch 'feat/otel-tracing-transport-logic' into feat/otel-traci…
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."""

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(
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).
"""
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)
9 changes: 9 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,9 @@ 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. If not set, the global tracer provider
will be used.

Raises:
ValueError: If both ``client_cert_source`` and ``client_encrypted_cert_source``
Expand All @@ -117,6 +124,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 +144,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()
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
Loading