diff --git a/packages/gapic-generator/gapic/schema/mixins.py b/packages/gapic-generator/gapic/schema/mixins.py index d340ec1189ab..793bb4b3ef99 100644 --- a/packages/gapic-generator/gapic/schema/mixins.py +++ b/packages/gapic-generator/gapic/schema/mixins.py @@ -19,50 +19,60 @@ "DeleteOperation", request_type="operations_pb2.DeleteOperationRequest", response_type="None", + rpc_name="google.longrunning.Operations/DeleteOperation", ), "WaitOperation": wrappers.MixinMethod( "WaitOperation", request_type="operations_pb2.WaitOperationRequest", response_type="operations_pb2.Operation", + rpc_name="google.longrunning.Operations/WaitOperation", ), "ListOperations": wrappers.MixinMethod( "ListOperations", request_type="operations_pb2.ListOperationsRequest", response_type="operations_pb2.ListOperationsResponse", + rpc_name="google.longrunning.Operations/ListOperations", ), "CancelOperation": wrappers.MixinMethod( "CancelOperation", request_type="operations_pb2.CancelOperationRequest", response_type="None", + rpc_name="google.longrunning.Operations/CancelOperation", ), "GetOperation": wrappers.MixinMethod( "GetOperation", request_type="operations_pb2.GetOperationRequest", response_type="operations_pb2.Operation", + rpc_name="google.longrunning.Operations/GetOperation", ), "TestIamPermissions": wrappers.MixinMethod( "TestIamPermissions", request_type="iam_policy_pb2.TestIamPermissionsRequest", response_type="iam_policy_pb2.TestIamPermissionsResponse", + rpc_name="google.iam.v1.IAMPolicy/TestIamPermissions", ), "GetIamPolicy": wrappers.MixinMethod( "GetIamPolicy", request_type="iam_policy_pb2.GetIamPolicyRequest", response_type="policy_pb2.Policy", + rpc_name="google.iam.v1.IAMPolicy/GetIamPolicy", ), "SetIamPolicy": wrappers.MixinMethod( "SetIamPolicy", request_type="iam_policy_pb2.SetIamPolicyRequest", response_type="policy_pb2.Policy", + rpc_name="google.iam.v1.IAMPolicy/SetIamPolicy", ), "ListLocations": wrappers.MixinMethod( "ListLocations", request_type="locations_pb2.ListLocationsRequest", response_type="locations_pb2.ListLocationsResponse", + rpc_name="google.cloud.location.Locations/ListLocations", ), "GetLocation": wrappers.MixinMethod( "GetLocation", request_type="locations_pb2.GetLocationRequest", response_type="locations_pb2.Location", + rpc_name="google.cloud.location.Locations/GetLocation", ), } diff --git a/packages/gapic-generator/gapic/schema/wrappers.py b/packages/gapic-generator/gapic/schema/wrappers.py index 9d17b77257c5..e1acba6b3a8d 100644 --- a/packages/gapic-generator/gapic/schema/wrappers.py +++ b/packages/gapic-generator/gapic/schema/wrappers.py @@ -1463,6 +1463,8 @@ class MixinMethod: name: str request_type: str response_type: str + rpc_name: str = "" + @dataclasses.dataclass(frozen=True) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index 01407a160d99..55bf60b8955a 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -11,6 +11,7 @@ from collections import OrderedDict import functools {% endif %} from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -53,6 +54,13 @@ try: except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) {% filter sort_lines %} @@ -314,17 +322,17 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): client_cert_source = mtls.default_client_cert_source() return client_cert_source - + def _validate_universe_domain(self): """Validates client's and credentials' universe domains are consistent. - + Returns: bool: True iff the configured universe domain is valid. Raises: ValueError: If the configured universe domain is not valid. """ - + # NOTE (b/349488459): universe validation is disabled until further notice. return True @@ -355,21 +363,21 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): @property def api_endpoint(self) -> str: """Return the API endpoint used by the client instance. - + Returns: str: The API endpoint used by the client instance. """ return self._api_endpoint - + @property def universe_domain(self) -> str: """Return the universe domain used by the client instance. - + Returns: str: The universe domain used by the client instance. """ return self._universe_domain - + def __init__(self, *, credentials: Optional[ga_credentials.Credentials] = None, transport: Optional[Union[str, {{ service.name }}Transport, Callable[..., {{ service.name }}Transport]]] = None, @@ -397,8 +405,8 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): {% endif %} client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): Custom options for the client. - - 1. The ``api_endpoint`` property can be used to override the + + 1. The ``api_endpoint`` property can be used to override the default endpoint provided by the client when ``transport`` is not explicitly provided. Only if this property is not set and ``transport`` was not explicitly provided, the endpoint is @@ -415,7 +423,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): not provided, the default SSL client certificate will be used if present. If GOOGLE_API_USE_CLIENT_CERTIFICATE is "false" or not set, no client certificate will be used. - + 3. The ``universe_domain`` property can be used to override the default "googleapis.com" universe. Note that the ``api_endpoint`` property still takes precedence; and ``universe_domain`` is @@ -473,7 +481,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): self._transport = cast({{ service.name }}Transport, transport) self._api_endpoint = self._transport.host - self._api_endpoint = (self._api_endpoint or + self._api_endpoint = (self._api_endpoint or get_api_endpoint( api_override=self._client_options.api_endpoint, universe_domain=self._universe_domain, @@ -531,19 +539,34 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): else cast(Callable[..., {{ service.name }}Transport], transport) ) {% endif %} + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, {{ service.grpc_transport_name }}) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) - + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) + if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER _LOGGER.debug( @@ -827,7 +850,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): gapic_v1.routing_header.to_grpc_metadata( (("resource", request_pb.resource),)), ) - + # Validate the universe domain. self._validate_universe_domain() diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 index f0cf1178da69..cd4c6b3fa9ad 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 @@ -3,6 +3,7 @@ {% block content %} import abc +import inspect from typing import {% if service.any_extended_operations_methods %}Any, {% endif %}Awaitable, Callable, Dict, Optional, Sequence, Union {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} @@ -10,6 +11,7 @@ from {{package_path}} import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -53,6 +55,13 @@ from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + ser DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class {{ service.name }}Transport(abc.ABC): """Abstract transport class for {{ service.name }}.""" @@ -75,6 +84,7 @@ class {{ service.name }}Transport(abc.ABC): client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -105,6 +115,9 @@ class {{ service.name }}Transport(abc.ABC): to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ {% if service.any_extended_operations_methods %} self._extended_operations_services: Dict[str, Any] = {} @@ -145,17 +158,39 @@ class {{ service.name }}Transport(abc.ABC): host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { {% for method in service.methods.values() %} - self.{{ method.transport_safe_name|snake_case }}: gapic_v1.method.wrap_method( + self.{{ method.transport_safe_name|snake_case }}: self._wrap_method( self.{{ method.transport_safe_name|snake_case }}, {% if method.retry %} default_retry=retries.Retry( @@ -178,13 +213,18 @@ class {{ service.name }}Transport(abc.ABC): {% endif %} default_timeout={{ method.timeout }}, client_info=client_info, + method_name="{{ '.'.join(method.meta.address.package) }}.{{ service.name }}/{{ method.name }}", + {% if method.client_streaming or method.server_streaming %} + is_streaming=True, + {% endif %} ), {% endfor %}{# method in service.methods.values() #} {% for method_name in api.mixin_api_methods.keys() %} - self.{{ method_name|snake_case }}: gapic_v1.method.wrap_method( + self.{{ method_name|snake_case }}: self._wrap_method( self.{{ method_name|snake_case }}, default_timeout=None, client_info=client_info, + method_name="{{ api.mixin_api_signatures[method_name].rpc_name }}", ), {% endfor %} {# method_name in api.mixin_api_methods.keys() #} } diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 index e906c9d9ea71..77b36589ba8c 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 @@ -8,20 +8,32 @@ import json import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore {% filter sort_lines %} @@ -80,7 +92,7 @@ class _LoggingClientInterceptor(grpc.UnaryUnaryClientInterceptor): # pragma: NO grpc_response = { "payload": response_payload, "metadata": metadata, - "status": "OK", + "status": "OK", } _LOGGER.debug( f"Received response for {client_call_details.method}.", @@ -123,6 +135,15 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -143,7 +164,7 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): ignored if a ``channel`` instance is provided. channel (Optional[Union[grpc.Channel, Callable[..., grpc.Channel]]]): A ``Channel`` instance through which to make calls, or a Callable - that constructs and returns one. If set to None, ``self.create_channel`` + that constructs and returns one. If set to None, ``self.create_channel`` is used to create the channel. If a Callable is given, it will be called with the same arguments as used in ``self.create_channel``. api_mtls_endpoint (Optional[str]): Deprecated. The mutual TLS endpoint. @@ -173,6 +194,12 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -231,6 +258,7 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -252,6 +280,22 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index 68e754caf287..7cf0ad2e2627 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -904,6 +904,129 @@ def test_{{ service.client_name|snake_case }}_client_options_from_dict(): ) +def test_{{ service.client_name|snake_case }}_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.client._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_transport_name }}, "__init__", return_value=None + ) as patched_transport_init, + ): + client = {{ service.client_name }}(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_{{ service.client_name|snake_case }}_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.client._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_transport_name }}, "__init__", return_value=None + ) as patched_transport_init, + ): + client = {{ service.client_name }}(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_{{ service.name|snake_case }}_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.{{ service.grpc_transport_name }}, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.{{ service.grpc_transport_name }}( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_{{ service.name|snake_case }}_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_transport_name }}, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.{{ service.grpc_transport_name }}( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_{{ service.name|snake_case }}_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.{{ service.grpc_transport_name }}( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ ({{ service.client_name }}, transports.{{ service.grpc_transport_name }}, "grpc", grpc_helpers), ({{ service.async_client_name }}, transports.{{ service.grpc_asyncio_transport_name }}, "grpc_asyncio", grpc_helpers_async), @@ -1246,6 +1369,41 @@ def test_{{ service.name|snake_case }}_base_transport_with_adc(): adc.assert_called_once() +def test_{{ service.name|snake_case }}_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }}.transports.{{ service.name }}Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.{{ service.name }}Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_{{ service.name|snake_case }}_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index a9724ae3b450..2dc40ae96b05 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -18,18 +18,18 @@ # PIP_INDEX_URL=https://pypi.org/simple nox from __future__ import absolute_import -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path + import os +import shutil import sys import tempfile import typing -import nox # type: ignore - +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from os import path -import shutil +from pathlib import Path +import nox # type: ignore nox.options.error_on_missing_interpreters = True @@ -407,6 +407,11 @@ def showcase( # Use pytest-asyncio<1.0.0 while we investigate the recent failure described in # https://github.com/googleapis/gapic-generator-python/issues/2399 session.install("pytest", "pytest-asyncio<1.0.0") + session.install( + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation-grpc", + ) test_directory = Path("tests", "system") ignore_file = env.get("IGNORE_FILE") pytest_command = [ @@ -498,7 +503,13 @@ def showcase_pqc( with showcase_library(session, templates=templates, other_opts=other_opts): session.install("pytest", "pytest-asyncio") session.install("--upgrade", "grpcio>=1.83.0", "grpcio-status>=1.83.0") - session.run("py.test", "--quiet", "--tls", *(session.posargs or ["tests/system/test_pqc.py"]), env=env) + session.run( + "py.test", + "--quiet", + "--tls", + *(session.posargs or ["tests/system/test_pqc.py"]), + env=env, + ) def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False): @@ -508,6 +519,8 @@ def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False "pytest-cov", "pytest-xdist", "pytest-asyncio", + "opentelemetry-api", + "opentelemetry-sdk", ) # Freeze and print python environment package versions session.run("python", "-m", "pip", "freeze") diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/client.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/client.py index ffc75791c484..cc1b47b9cb95 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/client.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.asset_v1.services.asset_service import pagers @@ -545,18 +553,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., AssetServiceTransport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, AssetServiceGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py index 2afbe7e1d6c8..2ca57c8ab8f7 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.asset_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -35,6 +37,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class AssetServiceTransport(abc.ABC): """Abstract transport class for AssetService.""" @@ -55,6 +64,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -85,6 +95,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -122,26 +135,50 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.export_assets: gapic_v1.method.wrap_method( + self.export_assets: self._wrap_method( self.export_assets, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ExportAssets", ), - self.list_assets: gapic_v1.method.wrap_method( + self.list_assets: self._wrap_method( self.list_assets, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ListAssets", ), - self.batch_get_assets_history: gapic_v1.method.wrap_method( + self.batch_get_assets_history: self._wrap_method( self.batch_get_assets_history, default_retry=retries.Retry( initial=0.1, @@ -155,13 +192,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/BatchGetAssetsHistory", ), - self.create_feed: gapic_v1.method.wrap_method( + self.create_feed: self._wrap_method( self.create_feed, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/CreateFeed", ), - self.get_feed: gapic_v1.method.wrap_method( + self.get_feed: self._wrap_method( self.get_feed, default_retry=retries.Retry( initial=0.1, @@ -175,8 +214,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/GetFeed", ), - self.list_feeds: gapic_v1.method.wrap_method( + self.list_feeds: self._wrap_method( self.list_feeds, default_retry=retries.Retry( initial=0.1, @@ -190,13 +230,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ListFeeds", ), - self.update_feed: gapic_v1.method.wrap_method( + self.update_feed: self._wrap_method( self.update_feed, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/UpdateFeed", ), - self.delete_feed: gapic_v1.method.wrap_method( + self.delete_feed: self._wrap_method( self.delete_feed, default_retry=retries.Retry( initial=0.1, @@ -210,8 +252,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/DeleteFeed", ), - self.search_all_resources: gapic_v1.method.wrap_method( + self.search_all_resources: self._wrap_method( self.search_all_resources, default_retry=retries.Retry( initial=0.1, @@ -225,8 +268,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=15.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/SearchAllResources", ), - self.search_all_iam_policies: gapic_v1.method.wrap_method( + self.search_all_iam_policies: self._wrap_method( self.search_all_iam_policies, default_retry=retries.Retry( initial=0.1, @@ -240,8 +284,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=15.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/SearchAllIamPolicies", ), - self.analyze_iam_policy: gapic_v1.method.wrap_method( + self.analyze_iam_policy: self._wrap_method( self.analyze_iam_policy, default_retry=retries.Retry( initial=0.1, @@ -254,71 +299,85 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=300.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeIamPolicy", ), - self.analyze_iam_policy_longrunning: gapic_v1.method.wrap_method( + self.analyze_iam_policy_longrunning: self._wrap_method( self.analyze_iam_policy_longrunning, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeIamPolicyLongrunning", ), - self.analyze_move: gapic_v1.method.wrap_method( + self.analyze_move: self._wrap_method( self.analyze_move, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeMove", ), - self.query_assets: gapic_v1.method.wrap_method( + self.query_assets: self._wrap_method( self.query_assets, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/QueryAssets", ), - self.create_saved_query: gapic_v1.method.wrap_method( + self.create_saved_query: self._wrap_method( self.create_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/CreateSavedQuery", ), - self.get_saved_query: gapic_v1.method.wrap_method( + self.get_saved_query: self._wrap_method( self.get_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/GetSavedQuery", ), - self.list_saved_queries: gapic_v1.method.wrap_method( + self.list_saved_queries: self._wrap_method( self.list_saved_queries, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ListSavedQueries", ), - self.update_saved_query: gapic_v1.method.wrap_method( + self.update_saved_query: self._wrap_method( self.update_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/UpdateSavedQuery", ), - self.delete_saved_query: gapic_v1.method.wrap_method( + self.delete_saved_query: self._wrap_method( self.delete_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/DeleteSavedQuery", ), - self.batch_get_effective_iam_policies: gapic_v1.method.wrap_method( + self.batch_get_effective_iam_policies: self._wrap_method( self.batch_get_effective_iam_policies, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/BatchGetEffectiveIamPolicies", ), - self.analyze_org_policies: gapic_v1.method.wrap_method( + self.analyze_org_policies: self._wrap_method( self.analyze_org_policies, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeOrgPolicies", ), - self.analyze_org_policy_governed_containers: gapic_v1.method.wrap_method( + self.analyze_org_policy_governed_containers: self._wrap_method( self.analyze_org_policy_governed_containers, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeOrgPolicyGovernedContainers", ), - self.analyze_org_policy_governed_assets: gapic_v1.method.wrap_method( + self.analyze_org_policy_governed_assets: self._wrap_method( self.analyze_org_policy_governed_assets, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeOrgPolicyGovernedAssets", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py index 848bb1096cbe..8189eaeb88c4 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.asset_v1.types import asset_service @@ -132,6 +144,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -182,6 +203,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -238,6 +265,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -259,6 +287,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py index e86b23c549e4..04819a637fbb 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py @@ -760,6 +760,129 @@ def test_asset_service_client_client_options_from_dict(): ) +def test_asset_service_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = AssetServiceClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_asset_service_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = AssetServiceClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_asset_service_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.AssetServiceGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.AssetServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_asset_service_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.AssetServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_asset_service_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.AssetServiceGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (AssetServiceClient, transports.AssetServiceGrpcTransport, "grpc", grpc_helpers), (AssetServiceAsyncClient, transports.AssetServiceGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -17335,6 +17458,41 @@ def test_asset_service_base_transport_with_adc(): adc.assert_called_once() +def test_asset_service_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.asset_v1.services.asset_service.transports.AssetServiceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.AssetServiceTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_asset_service_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/client.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/client.py index da065db5907b..217e3c0792c0 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/client.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.iam.credentials_v1.types import common @@ -482,18 +490,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., IAMCredentialsTransport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, IAMCredentialsGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py index 37bcbf2cb766..86402773c2f6 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.iam.credentials_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -32,6 +34,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class IAMCredentialsTransport(abc.ABC): """Abstract transport class for IAMCredentials.""" @@ -52,6 +61,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -82,6 +92,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -119,16 +132,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.generate_access_token: gapic_v1.method.wrap_method( + self.generate_access_token: self._wrap_method( self.generate_access_token, default_retry=retries.Retry( initial=0.1, @@ -142,8 +177,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/GenerateAccessToken", ), - self.generate_id_token: gapic_v1.method.wrap_method( + self.generate_id_token: self._wrap_method( self.generate_id_token, default_retry=retries.Retry( initial=0.1, @@ -157,8 +193,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/GenerateIdToken", ), - self.sign_blob: gapic_v1.method.wrap_method( + self.sign_blob: self._wrap_method( self.sign_blob, default_retry=retries.Retry( initial=0.1, @@ -172,8 +209,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/SignBlob", ), - self.sign_jwt: gapic_v1.method.wrap_method( + self.sign_jwt: self._wrap_method( self.sign_jwt, default_retry=retries.Retry( initial=0.1, @@ -187,6 +225,7 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/SignJwt", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py index 18428ad7d6e0..22d4c7239e2e 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py @@ -17,17 +17,29 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.iam.credentials_v1.types import common @@ -138,6 +150,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -188,6 +209,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -243,6 +270,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -264,6 +292,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py index a13fa010afd5..4cfda9621d70 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py @@ -750,6 +750,129 @@ def test_iam_credentials_client_client_options_from_dict(): ) +def test_iam_credentials_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.client._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IAMCredentialsClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_iam_credentials_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.client._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IAMCredentialsClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_iam_credentials_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.IAMCredentialsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.IAMCredentialsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_iam_credentials_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.IAMCredentialsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_iam_credentials_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.IAMCredentialsGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (IAMCredentialsClient, transports.IAMCredentialsGrpcTransport, "grpc", grpc_helpers), (IAMCredentialsAsyncClient, transports.IAMCredentialsGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -3749,6 +3872,41 @@ def test_iam_credentials_base_transport_with_adc(): adc.assert_called_once() +def test_iam_credentials_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.iam.credentials_v1.services.iam_credentials.transports.IAMCredentialsTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.IAMCredentialsTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_iam_credentials_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/client.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/client.py index f5442cba6179..52adfbed65e4 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/client.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.eventarc_v1.services.eventarc import pagers @@ -665,18 +673,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., EventarcTransport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, EventarcGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py index 3c054d084716..fd3fea7f587d 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.eventarc_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -47,6 +49,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class EventarcTransport(abc.ABC): """Abstract transport class for Eventarc.""" @@ -67,6 +76,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -97,6 +107,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -134,254 +147,324 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.get_trigger: gapic_v1.method.wrap_method( + self.get_trigger: self._wrap_method( self.get_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetTrigger", ), - self.list_triggers: gapic_v1.method.wrap_method( + self.list_triggers: self._wrap_method( self.list_triggers, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListTriggers", ), - self.create_trigger: gapic_v1.method.wrap_method( + self.create_trigger: self._wrap_method( self.create_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateTrigger", ), - self.update_trigger: gapic_v1.method.wrap_method( + self.update_trigger: self._wrap_method( self.update_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateTrigger", ), - self.delete_trigger: gapic_v1.method.wrap_method( + self.delete_trigger: self._wrap_method( self.delete_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteTrigger", ), - self.get_channel: gapic_v1.method.wrap_method( + self.get_channel: self._wrap_method( self.get_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetChannel", ), - self.list_channels: gapic_v1.method.wrap_method( + self.list_channels: self._wrap_method( self.list_channels, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListChannels", ), - self.create_channel_: gapic_v1.method.wrap_method( + self.create_channel_: self._wrap_method( self.create_channel_, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateChannel", ), - self.update_channel: gapic_v1.method.wrap_method( + self.update_channel: self._wrap_method( self.update_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateChannel", ), - self.delete_channel: gapic_v1.method.wrap_method( + self.delete_channel: self._wrap_method( self.delete_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteChannel", ), - self.get_provider: gapic_v1.method.wrap_method( + self.get_provider: self._wrap_method( self.get_provider, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetProvider", ), - self.list_providers: gapic_v1.method.wrap_method( + self.list_providers: self._wrap_method( self.list_providers, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListProviders", ), - self.get_channel_connection: gapic_v1.method.wrap_method( + self.get_channel_connection: self._wrap_method( self.get_channel_connection, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetChannelConnection", ), - self.list_channel_connections: gapic_v1.method.wrap_method( + self.list_channel_connections: self._wrap_method( self.list_channel_connections, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListChannelConnections", ), - self.create_channel_connection: gapic_v1.method.wrap_method( + self.create_channel_connection: self._wrap_method( self.create_channel_connection, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateChannelConnection", ), - self.delete_channel_connection: gapic_v1.method.wrap_method( + self.delete_channel_connection: self._wrap_method( self.delete_channel_connection, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteChannelConnection", ), - self.get_google_channel_config: gapic_v1.method.wrap_method( + self.get_google_channel_config: self._wrap_method( self.get_google_channel_config, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetGoogleChannelConfig", ), - self.update_google_channel_config: gapic_v1.method.wrap_method( + self.update_google_channel_config: self._wrap_method( self.update_google_channel_config, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateGoogleChannelConfig", ), - self.get_message_bus: gapic_v1.method.wrap_method( + self.get_message_bus: self._wrap_method( self.get_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetMessageBus", ), - self.list_message_buses: gapic_v1.method.wrap_method( + self.list_message_buses: self._wrap_method( self.list_message_buses, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListMessageBuses", ), - self.list_message_bus_enrollments: gapic_v1.method.wrap_method( + self.list_message_bus_enrollments: self._wrap_method( self.list_message_bus_enrollments, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListMessageBusEnrollments", ), - self.create_message_bus: gapic_v1.method.wrap_method( + self.create_message_bus: self._wrap_method( self.create_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateMessageBus", ), - self.update_message_bus: gapic_v1.method.wrap_method( + self.update_message_bus: self._wrap_method( self.update_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateMessageBus", ), - self.delete_message_bus: gapic_v1.method.wrap_method( + self.delete_message_bus: self._wrap_method( self.delete_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteMessageBus", ), - self.get_enrollment: gapic_v1.method.wrap_method( + self.get_enrollment: self._wrap_method( self.get_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetEnrollment", ), - self.list_enrollments: gapic_v1.method.wrap_method( + self.list_enrollments: self._wrap_method( self.list_enrollments, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListEnrollments", ), - self.create_enrollment: gapic_v1.method.wrap_method( + self.create_enrollment: self._wrap_method( self.create_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateEnrollment", ), - self.update_enrollment: gapic_v1.method.wrap_method( + self.update_enrollment: self._wrap_method( self.update_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateEnrollment", ), - self.delete_enrollment: gapic_v1.method.wrap_method( + self.delete_enrollment: self._wrap_method( self.delete_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteEnrollment", ), - self.get_pipeline: gapic_v1.method.wrap_method( + self.get_pipeline: self._wrap_method( self.get_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetPipeline", ), - self.list_pipelines: gapic_v1.method.wrap_method( + self.list_pipelines: self._wrap_method( self.list_pipelines, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListPipelines", ), - self.create_pipeline: gapic_v1.method.wrap_method( + self.create_pipeline: self._wrap_method( self.create_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreatePipeline", ), - self.update_pipeline: gapic_v1.method.wrap_method( + self.update_pipeline: self._wrap_method( self.update_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdatePipeline", ), - self.delete_pipeline: gapic_v1.method.wrap_method( + self.delete_pipeline: self._wrap_method( self.delete_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeletePipeline", ), - self.get_google_api_source: gapic_v1.method.wrap_method( + self.get_google_api_source: self._wrap_method( self.get_google_api_source, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetGoogleApiSource", ), - self.list_google_api_sources: gapic_v1.method.wrap_method( + self.list_google_api_sources: self._wrap_method( self.list_google_api_sources, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListGoogleApiSources", ), - self.create_google_api_source: gapic_v1.method.wrap_method( + self.create_google_api_source: self._wrap_method( self.create_google_api_source, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateGoogleApiSource", ), - self.update_google_api_source: gapic_v1.method.wrap_method( + self.update_google_api_source: self._wrap_method( self.update_google_api_source, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateGoogleApiSource", ), - self.delete_google_api_source: gapic_v1.method.wrap_method( + self.delete_google_api_source: self._wrap_method( self.delete_google_api_source, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteGoogleApiSource", ), - self.get_location: gapic_v1.method.wrap_method( + self.get_location: self._wrap_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: gapic_v1.method.wrap_method( + self.list_locations: self._wrap_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.get_iam_policy: gapic_v1.method.wrap_method( + self.get_iam_policy: self._wrap_method( self.get_iam_policy, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/GetIamPolicy", ), - self.set_iam_policy: gapic_v1.method.wrap_method( + self.set_iam_policy: self._wrap_method( self.set_iam_policy, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/SetIamPolicy", ), - self.test_iam_permissions: gapic_v1.method.wrap_method( + self.test_iam_permissions: self._wrap_method( self.test_iam_permissions, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/TestIamPermissions", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: gapic_v1.method.wrap_method( + self.delete_operation: self._wrap_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py index ac5d9a0fbe92..be9025f227be 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.eventarc_v1.types import channel @@ -146,6 +158,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -196,6 +217,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -252,6 +279,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -273,6 +301,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py index 3720a1a84418..665e2e87e0c7 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py @@ -781,6 +781,129 @@ def test_eventarc_client_client_options_from_dict(): ) +def test_eventarc_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.client._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EventarcClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_eventarc_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.client._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EventarcClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_eventarc_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.EventarcGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.EventarcGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_eventarc_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.EventarcGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_eventarc_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.EventarcGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (EventarcClient, transports.EventarcGrpcTransport, "grpc", grpc_helpers), (EventarcAsyncClient, transports.EventarcGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -30776,6 +30899,41 @@ def test_eventarc_base_transport_with_adc(): adc.assert_called_once() +def test_eventarc_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.eventarc_v1.services.eventarc.transports.EventarcTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.EventarcTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_eventarc_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/client.py index 2ec9186dedc1..53a782c89be6 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.config_service_v2 import pagers @@ -538,18 +546,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ConfigServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, ConfigServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py index dada98436600..6b26bfdd24fd 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -35,6 +37,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,81 +138,116 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_buckets: gapic_v1.method.wrap_method( + self.list_buckets: self._wrap_method( self.list_buckets, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListBuckets", ), - self.get_bucket: gapic_v1.method.wrap_method( + self.get_bucket: self._wrap_method( self.get_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetBucket", ), - self.create_bucket_async: gapic_v1.method.wrap_method( + self.create_bucket_async: self._wrap_method( self.create_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucketAsync", ), - self.update_bucket_async: gapic_v1.method.wrap_method( + self.update_bucket_async: self._wrap_method( self.update_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucketAsync", ), - self.create_bucket: gapic_v1.method.wrap_method( + self.create_bucket: self._wrap_method( self.create_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucket", ), - self.update_bucket: gapic_v1.method.wrap_method( + self.update_bucket: self._wrap_method( self.update_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucket", ), - self.delete_bucket: gapic_v1.method.wrap_method( + self.delete_bucket: self._wrap_method( self.delete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteBucket", ), - self.undelete_bucket: gapic_v1.method.wrap_method( + self.undelete_bucket: self._wrap_method( self.undelete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UndeleteBucket", ), - self.list_views: gapic_v1.method.wrap_method( + self.list_views: self._wrap_method( self.list_views, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListViews", ), - self.get_view: gapic_v1.method.wrap_method( + self.get_view: self._wrap_method( self.get_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetView", ), - self.create_view: gapic_v1.method.wrap_method( + self.create_view: self._wrap_method( self.create_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateView", ), - self.update_view: gapic_v1.method.wrap_method( + self.update_view: self._wrap_method( self.update_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateView", ), - self.delete_view: gapic_v1.method.wrap_method( + self.delete_view: self._wrap_method( self.delete_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteView", ), - self.list_sinks: gapic_v1.method.wrap_method( + self.list_sinks: self._wrap_method( self.list_sinks, default_retry=retries.Retry( initial=0.1, @@ -214,8 +262,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListSinks", ), - self.get_sink: gapic_v1.method.wrap_method( + self.get_sink: self._wrap_method( self.get_sink, default_retry=retries.Retry( initial=0.1, @@ -230,13 +279,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSink", ), - self.create_sink: gapic_v1.method.wrap_method( + self.create_sink: self._wrap_method( self.create_sink, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateSink", ), - self.update_sink: gapic_v1.method.wrap_method( + self.update_sink: self._wrap_method( self.update_sink, default_retry=retries.Retry( initial=0.1, @@ -251,8 +302,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSink", ), - self.delete_sink: gapic_v1.method.wrap_method( + self.delete_sink: self._wrap_method( self.delete_sink, default_retry=retries.Retry( initial=0.1, @@ -267,28 +319,33 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteSink", ), - self.create_link: gapic_v1.method.wrap_method( + self.create_link: self._wrap_method( self.create_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateLink", ), - self.delete_link: gapic_v1.method.wrap_method( + self.delete_link: self._wrap_method( self.delete_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteLink", ), - self.list_links: gapic_v1.method.wrap_method( + self.list_links: self._wrap_method( self.list_links, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListLinks", ), - self.get_link: gapic_v1.method.wrap_method( + self.get_link: self._wrap_method( self.get_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetLink", ), - self.list_exclusions: gapic_v1.method.wrap_method( + self.list_exclusions: self._wrap_method( self.list_exclusions, default_retry=retries.Retry( initial=0.1, @@ -303,8 +360,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListExclusions", ), - self.get_exclusion: gapic_v1.method.wrap_method( + self.get_exclusion: self._wrap_method( self.get_exclusion, default_retry=retries.Retry( initial=0.1, @@ -319,18 +377,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetExclusion", ), - self.create_exclusion: gapic_v1.method.wrap_method( + self.create_exclusion: self._wrap_method( self.create_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateExclusion", ), - self.update_exclusion: gapic_v1.method.wrap_method( + self.update_exclusion: self._wrap_method( self.update_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateExclusion", ), - self.delete_exclusion: gapic_v1.method.wrap_method( + self.delete_exclusion: self._wrap_method( self.delete_exclusion, default_retry=retries.Retry( initial=0.1, @@ -345,46 +406,55 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteExclusion", ), - self.get_cmek_settings: gapic_v1.method.wrap_method( + self.get_cmek_settings: self._wrap_method( self.get_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetCmekSettings", ), - self.update_cmek_settings: gapic_v1.method.wrap_method( + self.update_cmek_settings: self._wrap_method( self.update_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateCmekSettings", ), - self.get_settings: gapic_v1.method.wrap_method( + self.get_settings: self._wrap_method( self.get_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSettings", ), - self.update_settings: gapic_v1.method.wrap_method( + self.update_settings: self._wrap_method( self.update_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSettings", ), - self.copy_log_entries: gapic_v1.method.wrap_method( + self.copy_log_entries: self._wrap_method( self.copy_log_entries, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CopyLogEntries", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index d8122989787f..164e83c216d9 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_config @@ -132,6 +144,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -182,6 +203,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -238,6 +265,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -259,6 +287,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py index dfaf6928a16d..1a479a753bae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.logging_service_v2 import pagers @@ -469,18 +477,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., LoggingServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, LoggingServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py index 32f2a037688d..c0750edf90ae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -34,6 +36,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,16 +138,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.delete_log: gapic_v1.method.wrap_method( + self.delete_log: self._wrap_method( self.delete_log, default_retry=retries.Retry( initial=0.1, @@ -149,8 +184,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/DeleteLog", ), - self.write_log_entries: gapic_v1.method.wrap_method( + self.write_log_entries: self._wrap_method( self.write_log_entries, default_retry=retries.Retry( initial=0.1, @@ -165,8 +201,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/WriteLogEntries", ), - self.list_log_entries: gapic_v1.method.wrap_method( + self.list_log_entries: self._wrap_method( self.list_log_entries, default_retry=retries.Retry( initial=0.1, @@ -181,8 +218,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListLogEntries", ), - self.list_monitored_resource_descriptors: gapic_v1.method.wrap_method( + self.list_monitored_resource_descriptors: self._wrap_method( self.list_monitored_resource_descriptors, default_retry=retries.Retry( initial=0.1, @@ -197,8 +235,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListMonitoredResourceDescriptors", ), - self.list_logs: gapic_v1.method.wrap_method( + self.list_logs: self._wrap_method( self.list_logs, default_retry=retries.Retry( initial=0.1, @@ -213,8 +252,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListLogs", ), - self.tail_log_entries: gapic_v1.method.wrap_method( + self.tail_log_entries: self._wrap_method( self.tail_log_entries, default_retry=retries.Retry( initial=0.1, @@ -229,21 +269,26 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=3600.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/TailLogEntries", + is_streaming=True, ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index eeb3a8564ee0..5e994ee69806 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py @@ -17,17 +17,29 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging @@ -131,6 +143,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -181,6 +202,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -236,6 +263,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -257,6 +285,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py index 7319be93a38c..0deb3709d39c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.metrics_service_v2 import pagers @@ -470,18 +478,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., MetricsServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, MetricsServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py index f8a9522a02f5..eae1ca61b467 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -34,6 +36,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,16 +138,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_log_metrics: gapic_v1.method.wrap_method( + self.list_log_metrics: self._wrap_method( self.list_log_metrics, default_retry=retries.Retry( initial=0.1, @@ -149,8 +184,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/ListLogMetrics", ), - self.get_log_metric: gapic_v1.method.wrap_method( + self.get_log_metric: self._wrap_method( self.get_log_metric, default_retry=retries.Retry( initial=0.1, @@ -165,13 +201,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/GetLogMetric", ), - self.create_log_metric: gapic_v1.method.wrap_method( + self.create_log_metric: self._wrap_method( self.create_log_metric, default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/CreateLogMetric", ), - self.update_log_metric: gapic_v1.method.wrap_method( + self.update_log_metric: self._wrap_method( self.update_log_metric, default_retry=retries.Retry( initial=0.1, @@ -186,8 +224,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/UpdateLogMetric", ), - self.delete_log_metric: gapic_v1.method.wrap_method( + self.delete_log_metric: self._wrap_method( self.delete_log_metric, default_retry=retries.Retry( initial=0.1, @@ -202,21 +241,25 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/DeleteLogMetric", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index 2b6003f77476..a92efdd6ab6c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py @@ -17,17 +17,29 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_metrics @@ -131,6 +143,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -181,6 +202,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -236,6 +263,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -257,6 +285,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py index 638aac7a87f8..15ba0aaa50ae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -736,6 +736,129 @@ def test_config_service_v2_client_client_options_from_dict(): ) +def test_config_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ConfigServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_config_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ConfigServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_config_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.ConfigServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (ConfigServiceV2Client, transports.ConfigServiceV2GrpcTransport, "grpc", grpc_helpers), (ConfigServiceV2AsyncClient, transports.ConfigServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -12693,6 +12816,41 @@ def test_config_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_config_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.config_service_v2.transports.ConfigServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ConfigServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_config_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py index e1a950c64f4c..631a8d3d83ae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -737,6 +737,129 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +def test_logging_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_logging_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_logging_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.LoggingServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (LoggingServiceV2Client, transports.LoggingServiceV2GrpcTransport, "grpc", grpc_helpers), (LoggingServiceV2AsyncClient, transports.LoggingServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -3285,6 +3408,41 @@ def test_logging_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_logging_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.logging_service_v2.transports.LoggingServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.LoggingServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_logging_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py index e2db5c8a9a2a..509491abdfcc 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -735,6 +735,129 @@ def test_metrics_service_v2_client_client_options_from_dict(): ) +def test_metrics_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MetricsServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_metrics_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MetricsServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_metrics_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.MetricsServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (MetricsServiceV2Client, transports.MetricsServiceV2GrpcTransport, "grpc", grpc_helpers), (MetricsServiceV2AsyncClient, transports.MetricsServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -3085,6 +3208,41 @@ def test_metrics_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_metrics_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.metrics_service_v2.transports.MetricsServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.MetricsServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_metrics_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/client.py index e136bf06d85d..0d26bf0e3fbd 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.config_service_v2 import pagers @@ -538,18 +546,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ConfigServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, ConfigServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py index dada98436600..6b26bfdd24fd 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -35,6 +37,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,81 +138,116 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_buckets: gapic_v1.method.wrap_method( + self.list_buckets: self._wrap_method( self.list_buckets, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListBuckets", ), - self.get_bucket: gapic_v1.method.wrap_method( + self.get_bucket: self._wrap_method( self.get_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetBucket", ), - self.create_bucket_async: gapic_v1.method.wrap_method( + self.create_bucket_async: self._wrap_method( self.create_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucketAsync", ), - self.update_bucket_async: gapic_v1.method.wrap_method( + self.update_bucket_async: self._wrap_method( self.update_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucketAsync", ), - self.create_bucket: gapic_v1.method.wrap_method( + self.create_bucket: self._wrap_method( self.create_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucket", ), - self.update_bucket: gapic_v1.method.wrap_method( + self.update_bucket: self._wrap_method( self.update_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucket", ), - self.delete_bucket: gapic_v1.method.wrap_method( + self.delete_bucket: self._wrap_method( self.delete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteBucket", ), - self.undelete_bucket: gapic_v1.method.wrap_method( + self.undelete_bucket: self._wrap_method( self.undelete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UndeleteBucket", ), - self.list_views: gapic_v1.method.wrap_method( + self.list_views: self._wrap_method( self.list_views, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListViews", ), - self.get_view: gapic_v1.method.wrap_method( + self.get_view: self._wrap_method( self.get_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetView", ), - self.create_view: gapic_v1.method.wrap_method( + self.create_view: self._wrap_method( self.create_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateView", ), - self.update_view: gapic_v1.method.wrap_method( + self.update_view: self._wrap_method( self.update_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateView", ), - self.delete_view: gapic_v1.method.wrap_method( + self.delete_view: self._wrap_method( self.delete_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteView", ), - self.list_sinks: gapic_v1.method.wrap_method( + self.list_sinks: self._wrap_method( self.list_sinks, default_retry=retries.Retry( initial=0.1, @@ -214,8 +262,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListSinks", ), - self.get_sink: gapic_v1.method.wrap_method( + self.get_sink: self._wrap_method( self.get_sink, default_retry=retries.Retry( initial=0.1, @@ -230,13 +279,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSink", ), - self.create_sink: gapic_v1.method.wrap_method( + self.create_sink: self._wrap_method( self.create_sink, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateSink", ), - self.update_sink: gapic_v1.method.wrap_method( + self.update_sink: self._wrap_method( self.update_sink, default_retry=retries.Retry( initial=0.1, @@ -251,8 +302,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSink", ), - self.delete_sink: gapic_v1.method.wrap_method( + self.delete_sink: self._wrap_method( self.delete_sink, default_retry=retries.Retry( initial=0.1, @@ -267,28 +319,33 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteSink", ), - self.create_link: gapic_v1.method.wrap_method( + self.create_link: self._wrap_method( self.create_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateLink", ), - self.delete_link: gapic_v1.method.wrap_method( + self.delete_link: self._wrap_method( self.delete_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteLink", ), - self.list_links: gapic_v1.method.wrap_method( + self.list_links: self._wrap_method( self.list_links, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListLinks", ), - self.get_link: gapic_v1.method.wrap_method( + self.get_link: self._wrap_method( self.get_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetLink", ), - self.list_exclusions: gapic_v1.method.wrap_method( + self.list_exclusions: self._wrap_method( self.list_exclusions, default_retry=retries.Retry( initial=0.1, @@ -303,8 +360,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListExclusions", ), - self.get_exclusion: gapic_v1.method.wrap_method( + self.get_exclusion: self._wrap_method( self.get_exclusion, default_retry=retries.Retry( initial=0.1, @@ -319,18 +377,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetExclusion", ), - self.create_exclusion: gapic_v1.method.wrap_method( + self.create_exclusion: self._wrap_method( self.create_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateExclusion", ), - self.update_exclusion: gapic_v1.method.wrap_method( + self.update_exclusion: self._wrap_method( self.update_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateExclusion", ), - self.delete_exclusion: gapic_v1.method.wrap_method( + self.delete_exclusion: self._wrap_method( self.delete_exclusion, default_retry=retries.Retry( initial=0.1, @@ -345,46 +406,55 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteExclusion", ), - self.get_cmek_settings: gapic_v1.method.wrap_method( + self.get_cmek_settings: self._wrap_method( self.get_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetCmekSettings", ), - self.update_cmek_settings: gapic_v1.method.wrap_method( + self.update_cmek_settings: self._wrap_method( self.update_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateCmekSettings", ), - self.get_settings: gapic_v1.method.wrap_method( + self.get_settings: self._wrap_method( self.get_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSettings", ), - self.update_settings: gapic_v1.method.wrap_method( + self.update_settings: self._wrap_method( self.update_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSettings", ), - self.copy_log_entries: gapic_v1.method.wrap_method( + self.copy_log_entries: self._wrap_method( self.copy_log_entries, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CopyLogEntries", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index d8122989787f..164e83c216d9 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_config @@ -132,6 +144,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -182,6 +203,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -238,6 +265,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -259,6 +287,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py index dfaf6928a16d..1a479a753bae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.logging_service_v2 import pagers @@ -469,18 +477,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., LoggingServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, LoggingServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py index 32f2a037688d..c0750edf90ae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -34,6 +36,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,16 +138,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.delete_log: gapic_v1.method.wrap_method( + self.delete_log: self._wrap_method( self.delete_log, default_retry=retries.Retry( initial=0.1, @@ -149,8 +184,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/DeleteLog", ), - self.write_log_entries: gapic_v1.method.wrap_method( + self.write_log_entries: self._wrap_method( self.write_log_entries, default_retry=retries.Retry( initial=0.1, @@ -165,8 +201,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/WriteLogEntries", ), - self.list_log_entries: gapic_v1.method.wrap_method( + self.list_log_entries: self._wrap_method( self.list_log_entries, default_retry=retries.Retry( initial=0.1, @@ -181,8 +218,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListLogEntries", ), - self.list_monitored_resource_descriptors: gapic_v1.method.wrap_method( + self.list_monitored_resource_descriptors: self._wrap_method( self.list_monitored_resource_descriptors, default_retry=retries.Retry( initial=0.1, @@ -197,8 +235,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListMonitoredResourceDescriptors", ), - self.list_logs: gapic_v1.method.wrap_method( + self.list_logs: self._wrap_method( self.list_logs, default_retry=retries.Retry( initial=0.1, @@ -213,8 +252,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/ListLogs", ), - self.tail_log_entries: gapic_v1.method.wrap_method( + self.tail_log_entries: self._wrap_method( self.tail_log_entries, default_retry=retries.Retry( initial=0.1, @@ -229,21 +269,26 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=3600.0, client_info=client_info, + method_name="google.logging.v2.LoggingServiceV2/TailLogEntries", + is_streaming=True, ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index eeb3a8564ee0..5e994ee69806 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py @@ -17,17 +17,29 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging @@ -131,6 +143,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -181,6 +202,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -236,6 +263,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -257,6 +285,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py index 46949c293cd9..9ba7f3a26ace 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.metrics_service_v2 import pagers @@ -470,18 +478,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., MetricsServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, MetricsServiceV2GrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py index f8a9522a02f5..eae1ca61b467 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.logging_v2 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -34,6 +36,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +67,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -88,6 +98,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -125,16 +138,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_log_metrics: gapic_v1.method.wrap_method( + self.list_log_metrics: self._wrap_method( self.list_log_metrics, default_retry=retries.Retry( initial=0.1, @@ -149,8 +184,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/ListLogMetrics", ), - self.get_log_metric: gapic_v1.method.wrap_method( + self.get_log_metric: self._wrap_method( self.get_log_metric, default_retry=retries.Retry( initial=0.1, @@ -165,13 +201,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/GetLogMetric", ), - self.create_log_metric: gapic_v1.method.wrap_method( + self.create_log_metric: self._wrap_method( self.create_log_metric, default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/CreateLogMetric", ), - self.update_log_metric: gapic_v1.method.wrap_method( + self.update_log_metric: self._wrap_method( self.update_log_metric, default_retry=retries.Retry( initial=0.1, @@ -186,8 +224,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/UpdateLogMetric", ), - self.delete_log_metric: gapic_v1.method.wrap_method( + self.delete_log_metric: self._wrap_method( self.delete_log_metric, default_retry=retries.Retry( initial=0.1, @@ -202,21 +241,25 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/DeleteLogMetric", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index 2b6003f77476..a92efdd6ab6c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py @@ -17,17 +17,29 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_metrics @@ -131,6 +143,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -181,6 +202,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -236,6 +263,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -257,6 +285,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py index c63237e51f6c..887df9002db0 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -736,6 +736,129 @@ def test_base_config_service_v2_client_client_options_from_dict(): ) +def test_base_config_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseConfigServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_base_config_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseConfigServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_config_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.ConfigServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (BaseConfigServiceV2Client, transports.ConfigServiceV2GrpcTransport, "grpc", grpc_helpers), (BaseConfigServiceV2AsyncClient, transports.ConfigServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -12693,6 +12816,41 @@ def test_config_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_config_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.config_service_v2.transports.ConfigServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ConfigServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_config_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py index e1a950c64f4c..631a8d3d83ae 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -737,6 +737,129 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +def test_logging_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_logging_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_logging_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.LoggingServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (LoggingServiceV2Client, transports.LoggingServiceV2GrpcTransport, "grpc", grpc_helpers), (LoggingServiceV2AsyncClient, transports.LoggingServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -3285,6 +3408,41 @@ def test_logging_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_logging_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.logging_service_v2.transports.LoggingServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.LoggingServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_logging_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py index 5cb0ed20e2b1..e39504297fed 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -735,6 +735,129 @@ def test_base_metrics_service_v2_client_client_options_from_dict(): ) +def test_base_metrics_service_v2_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseMetricsServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_base_metrics_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseMetricsServiceV2Client(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_metrics_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.MetricsServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (BaseMetricsServiceV2Client, transports.MetricsServiceV2GrpcTransport, "grpc", grpc_helpers), (BaseMetricsServiceV2AsyncClient, transports.MetricsServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -3085,6 +3208,41 @@ def test_metrics_service_v2_base_transport_with_adc(): adc.assert_called_once() +def test_metrics_service_v2_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.logging_v2.services.metrics_service_v2.transports.MetricsServiceV2Transport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.MetricsServiceV2Transport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_metrics_service_v2_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py index 7b2e7759cd73..57171bc73dd9 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -532,18 +540,33 @@ def __init__(self, *, if api_key_value and hasattr(google.auth._default, "get_api_key_credentials"): credentials = google.auth._default.get_api_key_credentials(api_key_value) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, CloudRedisGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py index 8e015f903a92..89afff8ed313 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.redis_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -35,6 +37,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,6 +64,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -85,6 +95,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -122,104 +135,144 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_instances: gapic_v1.method.wrap_method( + self.list_instances: self._wrap_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: gapic_v1.method.wrap_method( + self.get_instance: self._wrap_method( self.get_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/GetInstance", ), - self.get_instance_auth_string: gapic_v1.method.wrap_method( + self.get_instance_auth_string: self._wrap_method( self.get_instance_auth_string, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/GetInstanceAuthString", ), - self.create_instance: gapic_v1.method.wrap_method( + self.create_instance: self._wrap_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: gapic_v1.method.wrap_method( + self.update_instance: self._wrap_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.upgrade_instance: gapic_v1.method.wrap_method( + self.upgrade_instance: self._wrap_method( self.upgrade_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpgradeInstance", ), - self.import_instance: gapic_v1.method.wrap_method( + self.import_instance: self._wrap_method( self.import_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ImportInstance", ), - self.export_instance: gapic_v1.method.wrap_method( + self.export_instance: self._wrap_method( self.export_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ExportInstance", ), - self.failover_instance: gapic_v1.method.wrap_method( + self.failover_instance: self._wrap_method( self.failover_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/FailoverInstance", ), - self.delete_instance: gapic_v1.method.wrap_method( + self.delete_instance: self._wrap_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.reschedule_maintenance: gapic_v1.method.wrap_method( + self.reschedule_maintenance: self._wrap_method( self.reschedule_maintenance, default_timeout=None, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/RescheduleMaintenance", ), - self.get_location: gapic_v1.method.wrap_method( + self.get_location: self._wrap_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: gapic_v1.method.wrap_method( + self.list_locations: self._wrap_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: gapic_v1.method.wrap_method( + self.delete_operation: self._wrap_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: gapic_v1.method.wrap_method( + self.wait_operation: self._wrap_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index addfbf37e166..c337eb6c75a9 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -152,6 +164,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -202,6 +223,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -258,6 +285,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -279,6 +307,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py index 6bd8b8b5009c..315bc7f47fc4 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -768,6 +768,129 @@ def test_cloud_redis_client_client_options_from_dict(): ) +def test_cloud_redis_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_cloud_redis_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_cloud_redis_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.CloudRedisGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (CloudRedisClient, transports.CloudRedisGrpcTransport, "grpc", grpc_helpers), (CloudRedisAsyncClient, transports.CloudRedisGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -11355,6 +11478,41 @@ def test_cloud_redis_base_transport_with_adc(): adc.assert_called_once() +def test_cloud_redis_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.redis_v1.services.cloud_redis.transports.CloudRedisTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.CloudRedisTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_cloud_redis_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py index 771b0baa9989..00cee860fecc 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -47,6 +48,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -532,18 +540,33 @@ def __init__(self, *, if api_key_value and hasattr(google.auth._default, "get_api_key_credentials"): credentials = google.auth._default.get_api_key_credentials(api_key_value) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, CloudRedisGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py index 8b9a24ec87fa..0728b3dd001c 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.redis_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -35,6 +37,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,6 +64,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -85,6 +95,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -122,74 +135,108 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_instances: gapic_v1.method.wrap_method( + self.list_instances: self._wrap_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: gapic_v1.method.wrap_method( + self.get_instance: self._wrap_method( self.get_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/GetInstance", ), - self.create_instance: gapic_v1.method.wrap_method( + self.create_instance: self._wrap_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: gapic_v1.method.wrap_method( + self.update_instance: self._wrap_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.delete_instance: gapic_v1.method.wrap_method( + self.delete_instance: self._wrap_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.get_location: gapic_v1.method.wrap_method( + self.get_location: self._wrap_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: gapic_v1.method.wrap_method( + self.list_locations: self._wrap_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: gapic_v1.method.wrap_method( + self.delete_operation: self._wrap_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: gapic_v1.method.wrap_method( + self.wait_operation: self._wrap_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index cae682b3d0ae..f17d519b5563 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -152,6 +164,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -202,6 +223,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -258,6 +285,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -279,6 +307,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py index 44a69d3d2277..8641b3453fa9 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -768,6 +768,129 @@ def test_cloud_redis_client_client_options_from_dict(): ) +def test_cloud_redis_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_cloud_redis_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_cloud_redis_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.CloudRedisGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (CloudRedisClient, transports.CloudRedisGrpcTransport, "grpc", grpc_helpers), (CloudRedisAsyncClient, transports.CloudRedisGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -6593,6 +6716,41 @@ def test_cloud_redis_base_transport_with_adc(): adc.assert_called_once() +def test_cloud_redis_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.redis_v1.services.cloud_redis.transports.CloudRedisTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.CloudRedisTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_cloud_redis_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py index ee8cac5e7107..1e22b8de746f 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py @@ -15,6 +15,7 @@ # from collections import OrderedDict from http import HTTPStatus +import inspect import json import logging as std_logging import os @@ -49,6 +50,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.36.0+; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -506,18 +514,33 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., StorageBatchOperationsTransport], transport) ) + # When OpenTelemetry tracing is enabled, pass client_options to the transport + # so it can wire tracing interceptors and method spans. + client_options = None + if ( + _observability is not None + and _observability.is_otel_capabilities_enabled(self._client_options) + and ( + not isinstance(transport_init, type) + or issubclass(transport_init, StorageBatchOperationsGrpcTransport) + ) + ): + client_options = self._client_options + # initialize with the provided callable or the passed in class - self._transport = transport_init( - credentials=credentials, - credentials_file=self._client_options.credentials_file, - host=self._api_endpoint, - scopes=self._client_options.scopes, - client_cert_source_for_mtls=self._client_cert_source, - quota_project_id=self._client_options.quota_project_id, - client_info=client_info, - always_use_jwt_access=True, - api_audience=self._client_options.api_audience, - ) + transport_kwargs = { + "credentials": credentials, + "credentials_file": self._client_options.credentials_file, + "host": self._api_endpoint, + "scopes": self._client_options.scopes, + "client_cert_source_for_mtls": self._client_cert_source, + "quota_project_id": self._client_options.quota_project_id, + "client_info": client_info, + "always_use_jwt_access": True, + "api_audience": self._client_options.api_audience, + **({"client_options": client_options} if client_options is not None else {}), + } + self._transport = transport_init(**transport_kwargs) if "async" not in str(self._transport): if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py index 1b5920f9153c..cefe275299c3 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py @@ -14,12 +14,14 @@ # limitations under the License. # import abc +import inspect from typing import Awaitable, Callable, Dict, Optional, Sequence, Union from google.cloud.storagebatchoperations_v1 import gapic_version as package_version import google.auth # type: ignore import google.api_core +from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries @@ -37,6 +39,13 @@ DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__) DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ +# Check once at module load time whether google-api-core's wrap_method supports +# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind) +# to avoid recurring inspect.signature latency during client instantiation. +_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters +) + class StorageBatchOperationsTransport(abc.ABC): """Abstract transport class for StorageBatchOperations.""" @@ -57,6 +66,7 @@ def __init__( client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, **kwargs, ) -> None: """Instantiate the transport. @@ -87,6 +97,9 @@ def __init__( to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Save the scopes. @@ -124,16 +137,38 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options + self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING + self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_tracing: + kwargs["client_options"] = self._client_options + try: + kwargs["kind"] = self.kind + # The abstract BaseTransport class raises NotImplementedError for the kind property. + # Concrete transport subclasses (gRPC, REST) override kind, so this exception handler + # is unreachable during normal execution. Excluded from coverage check. + except NotImplementedError: # pragma: NO COVER + pass + return gapic_v1.method.wrap_method(func, *args, **kwargs) + # The fallback below strips tracing-specific arguments when an older version + # of google-api-core is installed (which does not accept client_options, etc.). + # Excluded from coverage because our CI and testing environments always install + # a modern version of google-api-core that supports tracing. + for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER + kwargs.pop(k, None) # pragma: NO COVER + return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER + def _prep_wrapped_messages(self, client_info): # Precompute the wrapped methods. self._wrapped_methods = { - self.list_jobs: gapic_v1.method.wrap_method( + self.list_jobs: self._wrap_method( self.list_jobs, default_retry=retries.Retry( initial=1.0, @@ -146,8 +181,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/ListJobs", ), - self.get_job: gapic_v1.method.wrap_method( + self.get_job: self._wrap_method( self.get_job, default_retry=retries.Retry( initial=1.0, @@ -160,18 +196,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/GetJob", ), - self.create_job: gapic_v1.method.wrap_method( + self.create_job: self._wrap_method( self.create_job, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/CreateJob", ), - self.delete_job: gapic_v1.method.wrap_method( + self.delete_job: self._wrap_method( self.delete_job, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/DeleteJob", ), - self.cancel_job: gapic_v1.method.wrap_method( + self.cancel_job: self._wrap_method( self.cancel_job, default_retry=retries.Retry( initial=1.0, @@ -184,8 +223,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/CancelJob", ), - self.list_bucket_operations: gapic_v1.method.wrap_method( + self.list_bucket_operations: self._wrap_method( self.list_bucket_operations, default_retry=retries.Retry( initial=1.0, @@ -198,8 +238,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/ListBucketOperations", ), - self.get_bucket_operation: gapic_v1.method.wrap_method( + self.get_bucket_operation: self._wrap_method( self.get_bucket_operation, default_retry=retries.Retry( initial=1.0, @@ -212,36 +253,43 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/GetBucketOperation", ), - self.get_location: gapic_v1.method.wrap_method( + self.get_location: self._wrap_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: gapic_v1.method.wrap_method( + self.list_locations: self._wrap_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: gapic_v1.method.wrap_method( + self.cancel_operation: self._wrap_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: gapic_v1.method.wrap_method( + self.delete_operation: self._wrap_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: gapic_v1.method.wrap_method( + self.get_operation: self._wrap_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: gapic_v1.method.wrap_method( + self.list_operations: self._wrap_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py index 1f997d49aabd..bfa6dfff4e7f 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py @@ -17,18 +17,30 @@ import logging as std_logging import pickle import warnings -from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union, TYPE_CHECKING +import grpc # type: ignore from google.api_core import grpc_helpers + +if TYPE_CHECKING: # pragma: NO COVER + # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +# The _observability module was introduced in google-api-core 2.36.0+. +# On older versions of google-api-core or when type-checking against them, +# mypy may flag attr-defined or assignment errors when fallback to None occurs. +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -138,6 +150,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -188,6 +209,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -244,6 +271,7 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, ) if not self._grpc_channel: @@ -265,6 +293,22 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] + if ( + _observability is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and otel_interceptor not in channel_interceptors + and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) + ): + channel_interceptors.append(otel_interceptor) + + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py index 91d1b992fe18..4e245aed3eb7 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py @@ -760,6 +760,129 @@ def test_storage_batch_operations_client_client_options_from_dict(): ) +def test_storage_batch_operations_client_otel_channel_injection_enabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = True + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.client._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = StorageBatchOperationsClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("client_options") == client._client_options + + +def test_storage_batch_operations_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.is_otel_capabilities_enabled.return_value = False + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.client._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = StorageBatchOperationsClient(transport="grpc") + + mock_obs.is_otel_capabilities_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("client_options") + + +def test_storage_batch_operations_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.StorageBatchOperationsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_storage_batch_operations_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.grpc._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + options = client_options.ClientOptions() + transport = transports.StorageBatchOperationsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_interceptor.assert_called_once_with(options) + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_otel_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_storage_batch_operations_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.StorageBatchOperationsGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (StorageBatchOperationsClient, transports.StorageBatchOperationsGrpcTransport, "grpc", grpc_helpers), (StorageBatchOperationsAsyncClient, transports.StorageBatchOperationsGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -6658,6 +6781,41 @@ def test_storage_batch_operations_base_transport_with_adc(): adc.assert_called_once() +def test_storage_batch_operations_base_transport_wrap_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method.wrap_method", mock_wrap): + options = client_options.ClientOptions() + with mock.patch.object(google.auth, 'default', autospec=True) as adc, mock.patch('google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.StorageBatchOperationsTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.StorageBatchOperationsTransport(client_options=options) + + # Mock the kind property to return a value + with mock.patch.object(type(transport), "kind", new_callable=mock.PropertyMock) as mock_kind: + mock_kind.return_value = "grpc" + + # Test modern google-api-core with tracing support + transport._wrap_with_tracing = True + func = mock.Mock() + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc" + + # Test older google-api-core without tracing support + mock_wrap.reset_mock() + transport._wrap_with_tracing = False + transport._wrap_method(func, client_options=options, kind="grpc") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for correct handling of abstract base transport NotImplementedError + mock_wrap.reset_mock() + mock_kind.side_effect = NotImplementedError + transport._wrap_with_tracing = True + transport._wrap_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert "kind" not in mock_wrap.call_args.kwargs + + def test_storage_batch_operations_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/system/conftest.py b/packages/gapic-generator/tests/system/conftest.py index 73169dd8a79f..d001b135b8e1 100644 --- a/packages/gapic-generator/tests/system/conftest.py +++ b/packages/gapic-generator/tests/system/conftest.py @@ -13,17 +13,21 @@ # limitations under the License. -import grpc -from unittest import mock import os -import pytest -import pytest_asyncio -from requests.adapters import HTTPAdapter - from typing import Sequence, Tuple +from unittest import mock +import grpc +import pytest +import pytest_asyncio from google.api_core.client_options import ClientOptions # type: ignore from google.showcase_v1beta1.services.echo.transports import EchoRestInterceptor +from requests.adapters import HTTPAdapter + +try: + from google.api_core import _observability +except ImportError: + _observability = None try: from google.auth.aio import credentials as ga_credentials_async @@ -34,20 +38,18 @@ HAS_GOOGLE_AUTH_AIO = False import google.auth from google.auth import credentials as ga_credentials -from google.showcase import EchoClient -from google.showcase import IdentityClient -from google.showcase import MessagingClient +from google.showcase import EchoClient, IdentityClient, MessagingClient if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": - from grpc.experimental import aio import asyncio - from google.showcase import EchoAsyncClient - from google.showcase import IdentityAsyncClient + + from google.showcase import EchoAsyncClient, IdentityAsyncClient + from grpc.experimental import aio try: from google.showcase_v1beta1.services.echo.transports import ( - AsyncEchoRestTransport, AsyncEchoRestInterceptor, + AsyncEchoRestTransport, ) HAS_ASYNC_REST_ECHO_TRANSPORT = True @@ -132,8 +134,8 @@ def callback(): return cert, key -client_options = ClientOptions() -client_options.client_cert_source = callback +default_mtls_client_options = ClientOptions() +default_mtls_client_options.client_cert_source = callback def pytest_addoption(parser): @@ -141,7 +143,9 @@ def pytest_addoption(parser): "--mtls", action="store_true", help="Run system test with mutual TLS channel" ) parser.addoption( - "--tls", action="store_true", help="Run system test with standard one-way TLS channel" + "--tls", + action="store_true", + help="Run system test with standard one-way TLS channel", ) @@ -153,6 +157,7 @@ def construct_client( channel_creator=grpc.insecure_channel, # for grpc,grpc_asyncio only credentials=ga_credentials.AnonymousCredentials(), transport_endpoint="localhost:7469", + client_options=None, ): if use_mtls: with mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"}): @@ -162,7 +167,7 @@ def construct_client( mock_ssl_cred.return_value = ssl_credentials client = client_class( credentials=credentials, - client_options=client_options, + client_options=client_options or default_mtls_client_options, ) mock_ssl_cred.assert_called_once_with( certificate_chain=cert, private_key=key @@ -173,10 +178,13 @@ def construct_client( if transport_name in ["grpc", "grpc_asyncio"]: # TODO(gapic-generator-python/issues/1914): Need to test grpc transports without a channel_creator assert channel_creator - transport = transport_cls( - credentials=credentials, - channel=channel_creator(transport_endpoint), - ) + transport_kwargs = { + "credentials": credentials, + "channel": channel_creator(transport_endpoint), + } + if transport_name == "grpc": + transport_kwargs["client_options"] = client_options + transport = transport_cls(**transport_kwargs) elif transport_name in ["rest", "rest_asyncio"]: # The custom host explicitly bypasses https. transport = transport_cls( @@ -187,7 +195,7 @@ def construct_client( else: raise RuntimeError(f"Unexpected transport type: {transport_name}") - client = client_class(transport=transport) + client = client_class(transport=transport, client_options=client_options) return client @@ -340,7 +348,9 @@ def _read_response_metadata_stream(self): def intercept_unary_unary(self, continuation, client_call_details, request): self._add_request_metadata(client_call_details) response = continuation(client_call_details, request) - metadata = [(k, str(v)) for k, v in response.initial_metadata()] + [(k, str(v)) for k, v in response.trailing_metadata()] + metadata = [(k, str(v)) for k, v in response.initial_metadata()] + [ + (k, str(v)) for k, v in response.trailing_metadata() + ] self.response_metadata = metadata return response @@ -399,7 +409,9 @@ async def _add_request_metadata(self, client_call_details): async def intercept_unary_unary(self, continuation, client_call_details, request): await self._add_request_metadata(client_call_details) response = await continuation(client_call_details, request) - metadata = [(k, str(v)) for k, v in await response.initial_metadata()] + [(k, str(v)) for k, v in await response.trailing_metadata()] + metadata = [(k, str(v)) for k, v in await response.initial_metadata()] + [ + (k, str(v)) for k, v in await response.trailing_metadata() + ] self.response_metadata = metadata return response @@ -458,9 +470,13 @@ async def intercepted_echo_grpc_async(use_mtls, use_tls): ) host = "localhost:7469" if use_mtls: - channel = grpc.aio.secure_channel(host, ssl_credentials, interceptors=[interceptor]) + channel = grpc.aio.secure_channel( + host, ssl_credentials, interceptors=[interceptor] + ) elif use_tls: - channel = grpc.aio.secure_channel(host, tls_credentials, interceptors=[interceptor]) + channel = grpc.aio.secure_channel( + host, tls_credentials, interceptors=[interceptor] + ) else: channel = grpc.aio.insecure_channel(host, interceptors=[interceptor]) transport = EchoAsyncClient.get_transport_class("grpc_asyncio")( @@ -472,6 +488,7 @@ async def intercepted_echo_grpc_async(use_mtls, use_tls): class HostNameIgnoringAdapter(HTTPAdapter): """Custom HTTPAdapter that disables hostname verification for local self-signed certs.""" + def cert_verify(self, conn, url, verify, cert): super().cert_verify(conn, url, verify, cert) conn.assert_hostname = False diff --git a/packages/gapic-generator/tests/system/test_tracing.py b/packages/gapic-generator/tests/system/test_tracing.py new file mode 100644 index 000000000000..56f497968bfc --- /dev/null +++ b/packages/gapic-generator/tests/system/test_tracing.py @@ -0,0 +1,295 @@ +# 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 +# +# https://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 os +from unittest import mock + +import grpc +import pytest + +try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +if not HAS_OPENTELEMETRY: + pytest.skip("OpenTelemetry is not installed", allow_module_level=True) + +from google import showcase +from google.api_core import exceptions +from google.api_core import retry as retries +from google.api_core._feature_gating_helpers import FeatureGatingError +from google.api_core.client_options import ClientOptions +from google.auth import credentials as ga_credentials +from google.rpc import code_pb2 +from google.showcase import EchoClient + +try: + from .conftest import construct_client +except (ImportError, ValueError): + from conftest import construct_client + + +@pytest.fixture +def span_exporter(): + """Provides an isolated InMemorySpanExporter and TracerProvider for test assertions.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + processor = SimpleSpanProcessor(exporter) + provider.add_span_processor(processor) + + yield exporter, provider + + exporter.clear() + + +@pytest.fixture +def otel_echo_client(span_exporter, use_mtls): + """Constructs an EchoClient wired with an in-memory TracerProvider.""" + exporter, provider = span_exporter + options = ClientOptions( + tracer_provider=provider, + ) + with mock.patch.dict( + os.environ, {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "true"} + ): + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + yield client, exporter + + +def test_sync_unary_tracing(otel_echo_client): + """Verifies that a synchronous unary RPC generates trace spans with expected attributes.""" + client, exporter = otel_echo_client + + response = client.echo(showcase.EchoRequest(content="hello world")) + assert response.content == "hello world" + + spans = exporter.get_finished_spans() + # Synchronous unary calls generate both a Tier 2 method span and a Tier 4 wire span + assert len(spans) == 2 + + for span in spans: + assert span.name == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.system.name") == "grpc" + assert span.attributes.get("rpc.method") == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.response.status_code") == "OK" + assert span.kind == trace.SpanKind.CLIENT + + # Verify that the transport wire span captures url.domain + wire_spans = [s for s in spans if "url.domain" in s.attributes] + assert len(wire_spans) == 1 + assert wire_spans[0].attributes["url.domain"] == "googleapis.com" + + +def test_unary_retries_tracing(otel_echo_client): + """Verifies that each attempt of a retried RPC generates a separate span.""" + client, exporter = otel_echo_client + + # Configure a custom retry policy with 2 attempts on DeadlineExceeded + custom_retry = retries.Retry( + predicate=retries.if_exception_type(exceptions.DeadlineExceeded), + initial=0.05, + maximum=0.1, + multiplier=1.0, + deadline=0.3, + ) + + with pytest.raises((exceptions.DeadlineExceeded, exceptions.RetryError)): + client.echo( + { + "error": { + "code": code_pb2.Code.Value("DEADLINE_EXCEEDED"), + "message": "Simulated deadline exceeded error for retry testing.", + }, + }, + retry=custom_retry, + ) + + spans = exporter.get_finished_spans() + # At least two attempts should have been made and recorded + assert len(spans) >= 2 + for span in spans: + assert span.name == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.system.name") == "grpc" + assert span.attributes.get("rpc.method") == "google.showcase.v1beta1.Echo/Echo" + # Non-successful attempt should not have rpc.response.status_code == "OK" + assert span.attributes.get("rpc.response.status_code") != "OK" + + # Verify that the parent method span captures status.message for cross-language parity + parent_spans = [s for s in spans if s.parent is None] + assert len(parent_spans) == 1 + assert "status.message" in parent_spans[0].attributes + assert ( + "Simulated deadline exceeded error for retry testing." + in parent_spans[0].attributes["status.message"] + ) + + +def test_tracing_disabled_default(span_exporter, use_mtls): + """Verifies that default client options emit zero spans (zero overhead guarantee). + + Ensures that without setting GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED=true, + even if an ambient TracerProvider is active, zero spans are recorded and no + tracing overhead is incurred. Also verifies that passing tracer_provider without + the environment variable fails fast by raising FeatureGatingError. + """ + exporter, provider = span_exporter + + # Providing a tracer_provider without enabling the experimental env var fails fast + options_with_provider = ClientOptions( + tracer_provider=provider, + ) + with mock.patch.dict( + os.environ, {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "false"} + ): + with pytest.raises(FeatureGatingError): + construct_client( + EchoClient, + use_mtls, + client_options=options_with_provider, + credentials=ga_credentials.AnonymousCredentials(), + ) + + # Default client options emit zero spans + options = ClientOptions() + with mock.patch.dict( + os.environ, {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "false"} + ): + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + + response = client.echo(showcase.EchoRequest(content="no tracing")) + assert response.content == "no tracing" + + # Zero spans must be emitted when tracing is disabled + spans = exporter.get_finished_spans() + assert len(spans) == 0 + + +def test_custom_tracer_provider(use_mtls): + """Verifies that spans are emitted exclusively to the injected custom TracerProvider. + + Ensures strict isolation of trace data: when a client is configured with a + custom `TracerProvider`, generated RPC spans must be routed solely to that + provider's exporters and never leak into the ambient/global `TracerProvider`. + + Configures an ambient global `TracerProvider` with `global_exporter`, while + configuring the client with `custom_provider` and `custom_exporter`. After + executing an RPC, the test asserts that `custom_exporter` captured the span + while `global_exporter` recorded zero spans. + """ + custom_exporter = InMemorySpanExporter() + custom_provider = TracerProvider() + custom_provider.add_span_processor(SimpleSpanProcessor(custom_exporter)) + + global_exporter = InMemorySpanExporter() + global_provider = TracerProvider() + global_provider.add_span_processor(SimpleSpanProcessor(global_exporter)) + + # Temporarily set the ambient global tracer provider + original_provider = trace.get_tracer_provider() + trace.set_tracer_provider(global_provider) + try: + options = ClientOptions( + tracer_provider=custom_provider, + ) + with mock.patch.dict( + os.environ, {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "true"} + ): + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + + response = client.echo(showcase.EchoRequest(content="isolated trace")) + assert response.content == "isolated trace" + + custom_spans = custom_exporter.get_finished_spans() + assert len(custom_spans) == 2 + global_spans = global_exporter.get_finished_spans() + assert len(global_spans) == 0 + finally: + trace.set_tracer_provider(original_provider) + + +def test_direct_client_initialization_tracing(span_exporter): + """Verifies end-to-end trace injection via direct EchoClient instantiation. + + Validates the template wiring in `client.py.j2` directly. In system test + harnesses, `construct_client` often creates the transport instance manually, + which bypasses `client.py`'s `if not transport_provided:` branch. This test + instantiates `EchoClient(client_options=...)` directly to prove that the client + resolves `_observability.get_otel_interceptor` and passes it to `EchoGrpcTransport`. + + Constructs `EchoClient` without a pre-instantiated transport. Patches + `EchoGrpcTransport.create_channel` solely to target the local insecure Showcase + endpoint (`localhost:7469`). Executes `client.echo()` and asserts span generation. + """ + exporter, provider = span_exporter + options = ClientOptions( + tracer_provider=provider, + ) + + with mock.patch.dict( + os.environ, {"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "true"} + ): + with mock.patch.object( + EchoClient.get_transport_class("grpc"), + "create_channel", + side_effect=lambda host, **kwargs: grpc.insecure_channel("localhost:7469"), + ): + # Client constructs the transport and wires interceptors itself + client = EchoClient( + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + response = client.echo(showcase.EchoRequest(content="direct client wiring")) + assert response.content == "direct client wiring" + + spans = exporter.get_finished_spans() + assert len(spans) == 2 + for span in spans: + assert span.name == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.system.name") == "grpc" + + +def test_env_var_opt_in(otel_echo_client): + """Verifies that setting the environment variable enables tracing without tracing_enabled=True.""" + client, exporter = otel_echo_client + + response = client.echo(showcase.EchoRequest(content="env opt in")) + assert response.content == "env opt in" + + spans = exporter.get_finished_spans() + assert len(spans) == 2 + for span in spans: + assert span.name == "google.showcase.v1beta1.Echo/Echo" diff --git a/packages/gapic-generator/tests/unit/schema/test_api.py b/packages/gapic-generator/tests/unit/schema/test_api.py index 13ca7a009c86..0bef9ad02560 100644 --- a/packages/gapic-generator/tests/unit/schema/test_api.py +++ b/packages/gapic-generator/tests/unit/schema/test_api.py @@ -2836,6 +2836,9 @@ def test_mixin_api_signatures(): api_schema = api.API.build(fd, "google.example.v1", opts=opts) res = api_schema.mixin_api_signatures assert res == mixins.MIXINS_MAP + assert res["GetOperation"].rpc_name == "google.longrunning.Operations/GetOperation" + assert res["GetIamPolicy"].rpc_name == "google.iam.v1.IAMPolicy/GetIamPolicy" + assert res["GetLocation"].rpc_name == "google.cloud.location.Locations/GetLocation" def test_mixin_http_options(): diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 2d8c50acbfa9..ae68dbe942ee 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -185,7 +185,7 @@ def _grpc_client_response_hook(span: Any, response: Any) -> None: span: The OpenTelemetry span. response: The gRPC response object or details. """ - if not span.is_recording(): + if span is None or not getattr(span, "is_recording", lambda: False)(): return # Guard against upstream async calls that invoke this hook on failures. @@ -249,6 +249,7 @@ def get_otel_interceptor( def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: return otel_grpc.intercept_channel(channel, interceptor) + otel_interceptor._is_otel_interceptor = True # type: ignore[attr-defined] return otel_interceptor diff --git a/packages/google-api-core/google/api_core/gapic_v1/method.py b/packages/google-api-core/google/api_core/gapic_v1/method.py index 9b10b0392acf..656b841a2f26 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method.py @@ -216,11 +216,25 @@ def _extract_error_attributes(exc: Optional[Exception]) -> dict[str, Any]: reason = getattr(source, "reason", None) if reason: attrs["error.type"] = reason + else: + # Fallback per OpenTelemetry Semantic Conventions: every failed span should record + # a low-cardinality error.type. Use canonical status code name or exception class name. + status_code = _extract_status_code(target_exc) + attrs["error.type"] = status_code or target_exc.__class__.__name__ metadata = getattr(source, "metadata", None) if metadata: for k, v in metadata.items(): attrs[f"gcp.errors.metadata.{k}"] = str(v) + # 5. Extract human-readable error description for cross-language PRD parity + message = getattr(target_exc, "message", None) + if not message and hasattr(target_exc, "details") and callable(target_exc.details): + message = target_exc.details() + if not message and isinstance(target_exc, Exception): + message = str(target_exc) + if message: + attrs["status.message"] = str(message) + return attrs diff --git a/packages/google-api-core/tests/unit/gapic/test_method.py b/packages/google-api-core/tests/unit/gapic/test_method.py index a8d2197b0d6a..eed8e9949497 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method.py +++ b/packages/google-api-core/tests/unit/gapic/test_method.py @@ -525,9 +525,13 @@ def test_wrap_method_otel_tracing_enabled_error(mock_otel): wrapped() mock_target.assert_called_once() - mock_otel.span.set_attribute.assert_called_with( + mock_otel.span.set_attribute.assert_any_call( "rpc.response.status_code", "RuntimeError" ) + mock_otel.span.set_attribute.assert_any_call("error.type", "RuntimeError") + mock_otel.span.set_attribute.assert_any_call( + "status.message", "gRPC connection reset" + ) @pytest.mark.parametrize( @@ -547,7 +551,7 @@ def test_wrap_method_otel_tracing_enabled_error(mock_otel): def test_wrap_method_otel_tracing_error_status_code_mapping( mock_otel, exc, expected_status ): - """Proves that exceptions are cleanly mapped to canonical rpc.response.status_code names.""" + """Proves that exceptions are cleanly mapped to canonical rpc.response.status_code and error.type names.""" mock_target = mock.Mock(side_effect=exc) wrapped = google.api_core.gapic_v1.method.wrap_method( @@ -557,9 +561,12 @@ def test_wrap_method_otel_tracing_error_status_code_mapping( with pytest.raises(type(exc)): wrapped() - mock_otel.span.set_attribute.assert_called_with( + mock_otel.span.set_attribute.assert_any_call( "rpc.response.status_code", expected_status ) + mock_otel.span.set_attribute.assert_any_call("error.type", expected_status) + expected_msg = exc.cause.message if getattr(exc, "cause", None) else exc.message + mock_otel.span.set_attribute.assert_any_call("status.message", expected_msg) def test_wrap_method_otel_tracing_import_error(monkeypatch): @@ -687,11 +694,13 @@ def test_wrap_method_otel_tracing_attributes_no_service(mock_otel): def test_extract_error_attributes_standard_exception(): - """Proves that _extract_error_attributes returns empty dict for standard exceptions without ErrorInfo.""" - assert ( - google.api_core.gapic_v1.method._extract_error_attributes(ValueError("fail")) - == {} - ) + """Proves that _extract_error_attributes returns fallback error.type for exceptions without ErrorInfo.""" + assert google.api_core.gapic_v1.method._extract_error_attributes( + ValueError("fail") + ) == {"error.type": "ValueError", "status.message": "fail"} + assert google.api_core.gapic_v1.method._extract_error_attributes( + exceptions.InvalidArgument("invalid argument") + ) == {"error.type": "INVALID_ARGUMENT", "status.message": "invalid argument"} assert google.api_core.gapic_v1.method._extract_error_attributes(None) == {} @@ -745,6 +754,7 @@ def test_wrap_method_otel_tracing_records_gcp_error_attributes(mock_otel): mock_otel.span.set_attribute.assert_any_call( "gcp.errors.metadata.quota_limit", "100" ) + mock_otel.span.set_attribute.assert_any_call("status.message", "quota exceeded") def test_extract_status_code_variations(): @@ -838,12 +848,14 @@ def test_extract_error_attributes_variations(): "google.api_core.exceptions._parse_grpc_error_details", side_effect=ValueError("bad proto"), ): - assert _extract_error_attributes(exc_with_resp) == {} + assert _extract_error_attributes(exc_with_resp) == { + "error.type": "SimpleNamespace" + } # 4. error_info with empty domain, empty reason, empty metadata error_info_empty = types.SimpleNamespace(domain="", reason="", metadata=None) exc_empty = types.SimpleNamespace(error_info=error_info_empty) - assert _extract_error_attributes(exc_empty) == {} + assert _extract_error_attributes(exc_empty) == {"error.type": "SimpleNamespace"} # 5. else fallback where target_exc directly has domain, reason, and metadata exc_fallback = types.SimpleNamespace( @@ -863,7 +875,36 @@ def test_extract_error_attributes_variations(): reason="", metadata={}, ) - assert _extract_error_attributes(exc_fallback_empty) == {} + assert _extract_error_attributes(exc_fallback_empty) == { + "error.type": "SimpleNamespace" + } + + # 7. status.message extraction from .message attribute + exc_with_msg = types.SimpleNamespace(message="api call failed") + assert _extract_error_attributes(exc_with_msg) == { + "error.type": "SimpleNamespace", + "status.message": "api call failed", + } + + # 8. status.message extraction from .details() callable (e.g. gRPC RpcError) + exc_with_details = types.SimpleNamespace(details=lambda: "rpc deadline exceeded") + assert _extract_error_attributes(exc_with_details) == { + "error.type": "SimpleNamespace", + "status.message": "rpc deadline exceeded", + } + + # 9. status.message extraction from Exception string representation + exc_standard = ValueError("invalid argument passed") + assert _extract_error_attributes(exc_standard) == { + "error.type": "ValueError", + "status.message": "invalid argument passed", + } + + # 10. Exception with empty message string does not populate status.message + exc_empty_msg = ValueError("") + assert _extract_error_attributes(exc_empty_msg) == { + "error.type": "ValueError", + } def test_wrap_method_otel_tracing_partial_span_capabilities(mock_otel): @@ -881,9 +922,8 @@ def test_wrap_method_otel_tracing_partial_span_capabilities(mock_otel): ) with pytest.raises(ValueError): wrapped1() - mock_span1.set_attribute.assert_called_with( - "rpc.response.status_code", "ValueError" - ) + mock_span1.set_attribute.assert_any_call("rpc.response.status_code", "ValueError") + mock_span1.set_attribute.assert_any_call("error.type", "ValueError") # Test span without set_attribute (e.g. mock or stub lacking set_attribute) mock_span2 = mock.Mock(spec=[]) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 4d7a0d283fd1..f57203520afb 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -542,3 +542,29 @@ def test_grpc_client_response_hook_error_status_value(): mock_span.status.status_code.value = 2 _observability._grpc_client_response_hook(mock_span, mock.Mock()) mock_span.set_attribute.assert_not_called() + + +def test_grpc_client_response_hook_none_span(): + """Proves that _grpc_client_response_hook gracefully handles span=None without error.""" + _observability._grpc_client_response_hook(None, mock.Mock()) + + +def test_get_otel_interceptor_sentinel_attribute(monkeypatch): + """Proves that get_otel_interceptor tags the returned closure with _is_otel_interceptor=True.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + options = ClientOptions() + + mock_otel = mock.Mock() + 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.instrumentation.grpc, + ) + + interceptor = _observability.get_otel_interceptor(client_options=options) + assert callable(interceptor) + assert getattr(interceptor, "_is_otel_interceptor", None) is True