Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
38167cf
feat: Add OpenTelemetry environment variable and options configuratio…
chalmerlowe Jun 22, 2026
2db2cf1
feat(observability): add base OpenTelemetry span enricher interceptor
chalmerlowe Jun 22, 2026
dd07025
test(observability): add test-only environment variable overrides and…
chalmerlowe Jun 22, 2026
65dc83e
feat(observability): simplify options resolver to tracing-only
chalmerlowe Jun 24, 2026
f91ee49
feat(api-core): implement OtelUnaryClientInterceptor and feature gating
chalmerlowe Jul 24, 2026
e443dfe
chore(api-core): remove obsolete samples and fix pytest skip logic
chalmerlowe Jul 24, 2026
1073885
test(api-core): achieve 100% coverage for observability package
chalmerlowe Jul 24, 2026
01372b6
chore(api-core): remove custom interceptor prototype
chalmerlowe Aug 7, 2026
f68bc17
test(api-core): add failing tests for stock otel interceptor integration
chalmerlowe Aug 7, 2026
4698da9
feat(otel): implement E2E OpenTelemetry prototype for gRPC and Secret…
chalmerlowe Aug 7, 2026
63dd998
feat(otel): handle soft dependencies and fix tests for older runtimes
chalmerlowe Aug 7, 2026
6af4be8
fix(otel): resolve mypy errors and remove redundant checks
chalmerlowe Aug 7, 2026
6b5c41e
chore(api-core): remove empty observability module
chalmerlowe Aug 7, 2026
ca058d8
chore(otel): clarify test skip conditions and variable names
chalmerlowe Aug 7, 2026
b5afe98
refactor(otel): rename HAVE_* to HAS_* and add explanatory comments
chalmerlowe Aug 7, 2026
e0edc2c
refactor(otel): simplify mock setup in tests using automatic nested m…
chalmerlowe Aug 7, 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
22 changes: 19 additions & 3 deletions packages/google-api-core/google/api_core/grpc_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
import google.auth.transport.requests
import google.protobuf
import grpc

from google.api_core import exceptions, general_helpers
from google.api_core import _feature_gating_helpers, exceptions, general_helpers

# The list of gRPC Callable interfaces that return iterators.
_STREAM_WRAP_CLASSES = (grpc.UnaryStreamMultiCallable, grpc.StreamStreamMultiCallable)
Expand Down Expand Up @@ -384,10 +383,27 @@ def create_channel(
if attempt_direct_path:
target = _modify_target_for_direct_path(target)

return grpc.secure_channel(
channel = grpc.secure_channel(
target, composite_credentials, compression=compression, **kwargs
)

is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags(
env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
feature_key="tracer_provider",
configuration=None,
)

if is_tracing_enabled:
try:
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
interceptor = otel_grpc.client_interceptor()
channel = otel_grpc.intercept_channel(channel, interceptor)
except ImportError:
# If grpc dependency is missing, this should simply NOOP and fail open rather than failing import.
pass

return channel


def _modify_target_for_direct_path(target: str) -> str:
"""
Expand Down
3 changes: 3 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.27.0, < 2.0.0",
]
dynamic = ["version"]

Expand Down Expand Up @@ -91,4 +92,6 @@ filterwarnings = [
"ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning",
# Remove once https://github.com/grpc/grpc/issues/35086 is fixed (and version newer than 1.60.0 is published)
"ignore:There is no current event loop:DeprecationWarning",
# Ignore external OpenTelemetry/importlib.metadata SelectableGroups warning
"ignore:.*SelectableGroups dict interface is deprecated:DeprecationWarning",
]
1 change: 1 addition & 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,4 @@ requests==2.33.0
grpcio==1.59.0
grpcio-status==1.59.0
proto-plus==1.26.1
opentelemetry-api==1.27.0
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ grpcio==1.59.0
grpcio-status==1.59.0
proto-plus==1.26.1
aiohttp==3.13.4
opentelemetry-api==1.27.0
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@


import google.auth.credentials

from google.api_core import exceptions, grpc_helpers_async


Expand Down Expand Up @@ -421,9 +420,20 @@ def test_create_channel_implicit_with_default_host(
assert channel is grpc_secure_channel.return_value

google_auth_default.assert_called_once_with(scopes=None, default_scopes=None)
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host
)

# Suppressing metrics header prevents duplicate x-goog-api-client headers.
# We try both assertions to support older versions of google-auth.
try:
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials,
mock.sentinel.Request,
default_host=default_host,
suppress_metrics_header=True,
)
except AssertionError:
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host
)
grpc_secure_channel.assert_called_once_with(
expected_target, composite_creds, compression=None
)
Expand Down
20 changes: 15 additions & 5 deletions packages/google-api-core/tests/unit/test_grpc_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@
pytest.skip("No GRPC", allow_module_level=True)

import google.auth.credentials
from google.longrunning import operations_pb2

from google.api_core import exceptions, grpc_helpers
from google.longrunning import operations_pb2


def test__patch_callable_name():
Expand Down Expand Up @@ -453,9 +452,20 @@ def test_create_channel_implicit_with_default_host(
assert channel is grpc_secure_channel.return_value

google_auth_default.assert_called_once_with(scopes=None, default_scopes=None)
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host
)

# Suppressing metrics header prevents duplicate x-goog-api-client headers.
# We try both assertions to support older versions of google-auth.
try:
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials,
mock.sentinel.Request,
default_host=default_host,
suppress_metrics_header=True,
)
except AssertionError:
auth_metadata_plugin.assert_called_once_with(
mock.sentinel.credentials, mock.sentinel.Request, default_host=default_host
)

grpc_secure_channel.assert_called_once_with(
expected_target, composite_creds, compression=None
Expand Down
141 changes: 141 additions & 0 deletions packages/google-api-core/tests/unit/test_grpc_helpers_otel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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.

"""Tests for OpenTelemetry gRPC interceptor integration in google-api-core."""

import sys
from unittest import mock

import pytest

try:
from google.api_core import grpc_helpers

HAS_GRPC_HELPERS = True
except ImportError:
HAS_GRPC_HELPERS = False


# Removed clean_sys_modules fixture as it causes issues in no-grpc environments
# when tests are collected.


@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]")
def test_create_channel_otel_installed_and_enabled(monkeypatch):
"""Verify that create_channel wraps the channel with OTel interceptor when installed and enabled."""

# Build a hierarchy of mocks to simulate the nested OpenTelemetry modules.
# MagicMock automatically creates child mocks on attribute access.
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.side_effect = lambda ch, inc: f"wrapped_{ch}"

# Inject the mocks into sys.modules so Python's import system uses them.
modules = {
"opentelemetry": mock_otel,
"opentelemetry.instrumentation": mock_otel.instrumentation,
"opentelemetry.instrumentation.grpc": mock_otel_grpc,
}

for name, mod in modules.items():
monkeypatch.setitem(sys.modules, name, mod)

# Enable tracing
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")

# Mock grpc.secure_channel
mock_channel = "raw_channel"
with mock.patch(
"grpc.secure_channel", return_value=mock_channel
) as mock_secure_channel:
# We need to mock credentials setup to avoid external calls
with mock.patch(
"google.api_core.grpc_helpers._create_composite_credentials",
return_value=mock.Mock(),
):
channel = grpc_helpers.create_channel("localhost:1234")

# Verify raw channel was created
mock_secure_channel.assert_called_once()

# Verify OTel interceptor was fetched and channel was wrapped
mock_otel_grpc.client_interceptor.assert_called_once()
mock_otel_grpc.intercept_channel.assert_called_once_with(
mock_channel, mock_interceptor
)

# Verify returned channel is the wrapped one
assert channel == f"wrapped_{mock_channel}"


@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]")
def test_create_channel_otel_installed_but_disabled(monkeypatch):
"""Verify that create_channel does NOT wrap the channel if tracing is disabled."""

mock_otel_grpc = mock.Mock()
monkeypatch.setitem(
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
)

# Disable tracing (or leave unset, default should be false/disabled)
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "false")

mock_channel = "raw_channel"
with mock.patch(
"grpc.secure_channel", return_value=mock_channel
) as mock_secure_channel:
with mock.patch(
"google.api_core.grpc_helpers._create_composite_credentials",
return_value=mock.Mock(),
):
channel = grpc_helpers.create_channel("localhost:1234")

# Verify raw channel was created
mock_secure_channel.assert_called_once()

# Verify OTel was NOT used
mock_otel_grpc.intercept_channel.assert_not_called()

# Verify returned channel is the raw one
assert channel == mock_channel


@pytest.mark.skipif(not HAS_GRPC_HELPERS, reason="Requires google-api-core[grpc]")
def test_create_channel_otel_not_installed_fails_open(monkeypatch):
"""Verify that create_channel fails open if OTel is not installed, even if enabled."""

# Simulate missing module
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.grpc", None)

# Enable tracing
monkeypatch.setenv("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED", "true")

mock_channel = "raw_channel"
with mock.patch(
"grpc.secure_channel", return_value=mock_channel
) as mock_secure_channel:
with mock.patch(
"google.api_core.grpc_helpers._create_composite_credentials",
return_value=mock.Mock(),
):
# This should NOT raise ImportError
channel = grpc_helpers.create_channel("localhost:1234")

# Verify raw channel was created
mock_secure_channel.assert_called_once()

# Verify returned channel is the raw one
assert channel == mock_channel
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,24 @@
from google.api_core import exceptions as core_exceptions
from google.api_core import gapic_v1
from google.api_core import retry as retries

try:
from google.api_core import _feature_gating_helpers
HAS_FEATURE_GATING = True
except ImportError:
HAS_FEATURE_GATING = False
from google.auth import credentials as ga_credentials # type: ignore
from google.auth.exceptions import MutualTLSChannelError # type: ignore
from google.auth.transport import mtls # type: ignore
from google.auth.transport.grpc import SslCredentials # type: ignore
from google.cloud.secretmanager_v1 import gapic_version as package_version
from google.oauth2 import service_account # type: ignore

from google.cloud.secretmanager_v1 import gapic_version as package_version
try:
from opentelemetry import trace
HAS_OTEL = True
except ImportError:
HAS_OTEL = False

try:
OptionalRetry = Union[retries.Retry, gapic_v1.method._MethodDefault, None]
Expand All @@ -59,6 +70,11 @@
except ImportError: # pragma: NO COVER
CLIENT_LOGGING_SUPPORTED = False

if HAS_OTEL:
tracer = trace.get_tracer(__name__)
else:
tracer = None # type: ignore[assignment]

_LOGGER = std_logging.getLogger(__name__)

import google.iam.v1.iam_policy_pb2 as iam_policy_pb2 # type: ignore
Expand All @@ -68,7 +84,6 @@
import google.protobuf.field_mask_pb2 as field_mask_pb2 # type: ignore
import google.protobuf.timestamp_pb2 as timestamp_pb2 # type: ignore
from google.cloud.location import locations_pb2 # type: ignore

from google.cloud.secretmanager_v1.services.secret_manager_service import pagers
from google.cloud.secretmanager_v1.types import resources, service

Expand Down Expand Up @@ -1871,13 +1886,36 @@ def sample_access_secret_version():
# Validate the universe domain.
self._validate_universe_domain()

if HAS_FEATURE_GATING:
is_tracing_enabled = _feature_gating_helpers.resolve_feature_flags(
env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
feature_key="tracer_provider",
configuration=self._client_options,
)
else:
is_tracing_enabled = False

# Send the request.
response = rpc(
request,
retry=retry,
timeout=timeout,
metadata=metadata,
)
if is_tracing_enabled and HAS_OTEL and tracer:
with tracer.start_as_current_span(
"SecretManagerServiceClient.access_secret_version"
) as span:
span.set_attribute("gcp.secretmanager.secret.name", request.name)
response = rpc(
request,
retry=retry,
timeout=timeout,
metadata=metadata,
)
else:
response = rpc(
request,
retry=retry,
timeout=timeout,
metadata=metadata,
)

return response

# Done; return the response.
return response
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-secret-manager/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"pytest",
"pytest-cov",
"pytest-asyncio",
"opentelemetry-sdk",
]
UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = []
UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = []
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-secret-manager/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"proto-plus >= 1.26.1, <2.0.0",
"protobuf >= 6.33.5, < 8.0.0",
"grpc-google-iam-v1 >= 0.14.2, <1.0.0",
"opentelemetry-api >= 1.27.0, < 2.0.0",
]
extras = {}
url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-secret-manager"
Expand Down
Loading
Loading