diff --git a/.github/workflows/gapic-generator-tests.yml b/.github/workflows/gapic-generator-tests.yml index 6379fa492d41..d825900e2559 100644 --- a/.github/workflows/gapic-generator-tests.yml +++ b/.github/workflows/gapic-generator-tests.yml @@ -73,7 +73,7 @@ jobs: with: python-version: "${{ matrix.python }}" allow-prereleases: true - # Caches compiled wheels locally to prevent building heavy libraries + # Caches compiled wheels locally to prevent building heavy libraries # such as grpcio, which we build from scratch on every run for Python 3.15+. # Follow https://github.com/grpc/grpc/issues/41010 for updates. cache: 'pip' @@ -94,6 +94,40 @@ jobs: cd packages/gapic-generator nox -s showcase_unit${{ matrix.variant }}-${MATRIX_PYTHON} + showcase-system: + needs: python_config + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.10" + cache: 'pip' + cache-dependency-path: '**/requirements*.txt' + - name: Install System Deps & Protoc + run: | + sudo apt-get update && sudo apt-get install -y curl pandoc unzip + sudo mkdir -p /usr/src/protoc/ && sudo chown -R ${USER} /usr/src/ + curl --location https://github.com/google/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip --output /usr/src/protoc/protoc.zip + cd /usr/src/protoc/ && unzip protoc.zip + sudo ln -s /usr/src/protoc/bin/protoc /usr/local/bin/protoc + - name: Install & Run Showcase Daemon + run: | + sudo mkdir -p /usr/src/showcase && sudo chown -R ${USER} /usr/src/ + curl --location https://github.com/googleapis/gapic-showcase/releases/download/v${SHOWCASE_VERSION}/gapic-showcase-${SHOWCASE_VERSION}-linux-amd64.tar.gz --output /usr/src/showcase/showcase.tar.gz + cd /usr/src/showcase/ && tar -xf showcase.tar.gz + ./gapic-showcase run & + sleep 2 + curl -s http://localhost:7469/ > /dev/null || true + - name: Run Showcase System Tests + run: | + pip install nox + cd packages/gapic-generator + nox -s showcase-3.10 -- -v -s tests/system + showcase-mypy: needs: python_config runs-on: ubuntu-latest @@ -159,7 +193,7 @@ jobs: with: python-version: ${{ needs.python_config.outputs.prerelease_python }} allow-prereleases: true - # Caches compiled wheels locally to prevent building heavy libraries + # Caches compiled wheels locally to prevent building heavy libraries # such as grpcio, which we build from scratch on every run for Python 3.15+. # Follow https://github.com/grpc/grpc/issues/41010 for updates. cache: 'pip' @@ -189,7 +223,7 @@ jobs: with: python-version: ${{ matrix.python }} allow-prereleases: true - # Caches compiled wheels locally to prevent building heavy libraries + # Caches compiled wheels locally to prevent building heavy libraries # such as grpcio, which we build from scratch on every run for Python 3.15+. # Follow https://github.com/grpc/grpc/issues/41010 for updates. cache: 'pip' @@ -234,16 +268,16 @@ jobs: - name: Run Bazel Integration Tests run: | - # We need to move into the package directory if the + # We need to move into the package directory if the # WORKSPACE file is located there. cd packages/gapic-generator bazelisk test //tests/integration/... --test_output=errors # Final job to simplify branch protection rules. - # This job will be reported as 'Success' if all its dependencies either + # This job will be reported as 'Success' if all its dependencies either # pass or are skipped due to path filtering. status: - needs: [check_changes, python_config, showcase-unit, showcase-mypy, goldens, goldens-prerelease, fragment-snippet, integration] + needs: [check_changes, python_config, showcase-unit, showcase-system, showcase-mypy, goldens, goldens-prerelease, fragment-snippet, integration] if: always() runs-on: ubuntu-latest steps: @@ -261,4 +295,3 @@ jobs: exit 1 fi echo "All checks passed or were successfully skipped." - diff --git a/.gitignore b/.gitignore index efcdb4857b95..8d05247c3350 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ venv/ # Test logs coverage.xml *sponge_log.xml +raw_spans_output*.json # System test environment variables. **/system_tests/local_test_setup @@ -72,3 +73,6 @@ bazel-bin bazel-gapic-generator bazel-out bazel-testlogs + +# Telemetry raw spans dump +raw_spans_output-*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ebecd09ac76..ff608bb2a013 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,7 @@ # # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +exclude: '^packages/gapic-generator/tests/integration/goldens/' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 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/_compat.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 index aa450ddd327f..d34bb7258859 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 @@ -5,14 +5,15 @@ """A compatibility module for older versions of google-api-core.""" {% set has_auto_populated_fields = api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} {# TODO(https://github.com/googleapis/google-cloud-python/issues/17813): -Clean up this file/functions when the minimum supported version of +Clean up this file/functions when the minimum supported version of google-api-core has the functions in `_compat.py.j2`. #} -{# TODO(https://github.com/googleapis/google-cloud-python/issues/17884): -Add conditional logic to check if static code exists in google-api-core and use it from there, +{# TODO(https://github.com/googleapis/google-cloud-python/issues/17884): +Add conditional logic to check if static code exists in google-api-core and use it from there, falling back to the local implementation if not present. #} -{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883): +{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883): Backfill compatibility functions being removed from the client layer. #} +import contextlib import os import json {% if has_auto_populated_fields %} @@ -23,9 +24,6 @@ import google.protobuf.message from typing import Any, Dict, List, Optional, Tuple {% if has_auto_populated_fields %} from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: # pragma: NO COVER - import proto # type: ignore[import-untyped] {% endif %} from google.api_core import path_template @@ -34,9 +32,37 @@ from google.auth.exceptions import MutualTLSChannelError from google.protobuf import json_format from urllib.parse import urlparse, urlunparse +{% if has_auto_populated_fields %} +if TYPE_CHECKING: # pragma: NO COVER + import proto # type: ignore[import-untyped] +{% endif %} + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` - # is different than that of the fallback implementation below. This will be removed once + # is different than that of the fallback implementation below. This will be removed once # we bump the minimum supported version of google-auth. from google.auth.transport.mtls import should_use_client_cert # type: ignore except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 index e39425bb8117..1ec6f640de2e 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 @@ -159,30 +159,39 @@ def _get_http_options(): session, timeout, transcoded_request, - body=None): - + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" + uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = {{ await_prefix }}getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - {% if body_spec %} - data=body, - {% endif %} - {% if not is_async and is_streaming_method %} - {# NOTE: The underlying `requests` library used for making a sync request - # requires us to set `stream=True` to avoid loading the entire response - # into memory at once. For an async request, given its nature where it - # reads data chunk by chunk, this is not required. - #} - stream=True, - {% endif %} + body=body, + ) as span: + response = {{ await_prefix }}getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + {% if body_spec %} + data=body, + {% endif %} + {% if not is_async and is_streaming_method %} + stream=True, + {% endif %} ) - return response + record_http_response(span, response) + return response {% endmacro %} {# rest_call_method_common includes the common code for a rest __call__ method to be @@ -240,13 +249,24 @@ def _get_http_options(): ) # Send the request - response = {{ await_prefix }}{{ async_class_prefix }}{{ service_name }}RestTransport._{{method_name}}._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request{% if body_spec %}, body{% endif %}) + response = {{ await_prefix }}{{ async_class_prefix }}{{ service_name }}RestTransport._{{method_name}}._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + {% if body_spec %} + body, + {% endif %} + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. if response.status_code >= 400: - {# Note: format_http_response_error takes in more parameters than from_http_response and the - latter only supports a response of type requests.Response. + {# Note: format_http_response_error takes in more parameters than from_http_response and the + latter only supports a response of type requests.Response. TODO: Clean up the sync response error handling and use format_http_response_error. See issue: https://github.com/googleapis/gapic-generator-python/issues/2116. #} {% if is_async %} @@ -301,10 +321,13 @@ def _get_http_options(): {% macro prep_wrapped_messages_async_method(api, service) %} def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { {% for method in service.methods.values() %} - self.{{ method.transport_safe_name|snake_case }}: self._wrap_method( + self.{{ method.transport_safe_name|snake_case }}: self._wrap_async_method( self.{{ method.transport_safe_name|snake_case }}, {% if method.retry %} default_retry=retries.AsyncRetry( @@ -327,36 +350,26 @@ def _prep_wrapped_messages(self, client_info): {% 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 %}{# service.methods.values() #} {% for method_name in api.mixin_api_methods.keys() %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2197): Use `transport_safe_name` similar # to what we do for non-mixin methods above. #} - self.{{ method_name|snake_case }}: self._wrap_method( + self.{{ method_name|snake_case }}: self._wrap_async_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() #} } {% endmacro %} -{# TODO: This helper logic to check whether `kind` needs to be configured in wrap_method -can be removed once we require the correct version of the google-api-core dependency to -avoid having a gRPC code path in an async REST call. -See related issue: https://github.com/googleapis/python-api-core/issues/661. -In the meantime, if an older version of the dependency is installed (which has a wrap_method with -no kind parameter), then an async gRPC call will work correctly and async REST transport -will not be available as a transport. -See related issue: https://github.com/googleapis/gapic-generator-python/issues/2119. #} -{% macro wrap_async_method_macro() %} -def _wrap_method(self, func, *args, **kwargs): - {# TODO: Remove `pragma: NO COVER` once https://github.com/googleapis/python-api-core/pull/688 is merged. #} - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) -{% endmacro %} {# `create_interceptor_class` generates an Interceptor class for # synchronous and asynchronous rest transports @@ -437,11 +450,11 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: Override in a subclass to read or manipulate the response or metadata after it is returned by the {{ service.name }} server but before it is returned to user code. - + We recommend only using this `post_{{ method.name|snake_case }}_with_metadata` interceptor in new development instead of the `post_{{ method.name|snake_case }}` interceptor. When both interceptors are used, this `post_{{ method.name|snake_case }}_with_metadata` interceptor runs after the - `post_{{ method.name|snake_case }}` interceptor. The (possibly modified) response returned by + `post_{{ method.name|snake_case }}` interceptor. The (possibly modified) response returned by `post_{{ method.name|snake_case }}` will be passed to `post_{{ method.name|snake_case }}_with_metadata`. """ @@ -487,7 +500,7 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: @property def {{ name|snake_case }}(self): - return self.{{ name|make_private }}(self._session, self._host, self._interceptor) # type: ignore + return self.{{ name|make_private }}(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class {{ name|make_private }}(_Base{{ service.name }}RestTransport._Base{{name}}, {{ async_method_name_prefix }}{{service.name}}RestStub): def __hash__(self): @@ -495,7 +508,7 @@ class {{ name|make_private }}(_Base{{ service.name }}RestTransport._Base{{name}} {% set body_spec = api.mixin_http_options["{}".format(name)][0].body %} {{ response_method(body_spec, is_async=is_async, is_streaming_method=None) | indent(4) }} - + {{ async_prefix }}def __call__(self, request: {{ sig.request_type }}, *, retry: OptionalRetry=gapic_v1.method.DEFAULT, @@ -521,7 +534,7 @@ class {{ name|make_private }}(_Base{{ service.name }}RestTransport._Base{{name}} {% endif %} """ {{ rest_call_method_common(body_spec, name, service, is_async)|indent(4) }} - + {% if sig.response_type == "None" %} return {{ await_prefix }}self._interceptor.post_{{ name|snake_case }}(None) {% else %} @@ -543,7 +556,7 @@ class {{ name|make_private }}(_Base{{ service.name }}RestTransport._Base{{name}} "payload": response_payload, "headers": dict(response.headers), "status": response.status_code, - } + } _LOGGER.debug( "Received response for {{ service.meta.address.proto_package_versioned }}.{{ service.async_client_name }}.{{ name }}", extra = { @@ -565,7 +578,7 @@ class {{ name|make_private }}(_Base{{ service.name }}RestTransport._Base{{name}} {% macro client_method_metadata_default_value() %}(){% endmacro %} -{% macro client_method_metadata_argument_doc() %}metadata ({{ client_method_metadata_type() }}): Key/value pairs which should be +{% macro client_method_metadata_argument_doc() %}metadata ({{ client_method_metadata_type() }}): Key/value pairs which should be sent along with the request as metadata. Normally, each value must be of type `str`, but for metadata keys ending with the suffix `-bin`, the corresponding values must be of type `bytes`.{% endmacro %} 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..bcba26b04b0f 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 @@ -30,7 +30,14 @@ from google.api_core import exceptions as core_exceptions from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 -from {{package_path}}._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from {{package_path}}._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) {% if has_auto_populated_fields %} from {{package_path}}._compat import setup_request_id {% endif %} @@ -314,17 +321,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 +362,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 +404,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 +422,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 +480,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, @@ -510,10 +517,14 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): raise core_exceptions.AsyncRestUnsupportedParameterError( # type: ignore f"The following provided parameters are not supported for `transport=rest_asyncio`: {', '.join(provided_unsupported_params)}" ) + client_options = None + if _observability is not None and _observability.is_otel_capabilities_enabled(self._client_options): # pragma: NO COVER + client_options = self._client_options # pragma: NO COVER self._transport = transport_init( credentials=credentials, host=self._api_endpoint, client_info=client_info, + **({"client_options": client_options} if client_options is not None else {}), ) return @@ -531,19 +542,30 @@ 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) + ): + 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 +849,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..b89ac9708593 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class {{ service.name }}Transport(abc.ABC): """Abstract transport class for {{ service.name }}.""" @@ -75,6 +87,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 +118,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 +161,60 @@ class {{ service.name }}Transport(abc.ABC): host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +237,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() #} } @@ -370,7 +434,7 @@ class {{ service.name }}Transport(abc.ABC): @property def kind(self) -> str: - raise NotImplementedError() + return "" {% for operations_service in api.get_extended_operations_services(service)|sort(attribute="name") %} @property 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..dcbeb16fbc95 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,23 @@ 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 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 +{% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} +from {{package_path}}._compat import _observability 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 %} @@ -50,6 +53,11 @@ from google.longrunning import operations_pb2 # type: ignore {% endfilter %} from .base import {{ service.name }}Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -80,7 +88,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 +131,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 +160,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 +190,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 +254,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 +276,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/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 index 7b8a885d227c..ffb91096612b 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 @@ -3,9 +3,7 @@ {% import "%namespace/%name_%version/%sub/services/%service/_shared_macros.j2" as shared_macros %} {% block content %} -{% import "%namespace/%name_%version/%sub/services/%service/_shared_macros.j2" as shared_macros %} -import inspect import json import pickle import logging as std_logging @@ -19,6 +17,9 @@ from google.api_core import retry_async as retries {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} +from google.api_core import client_options as client_options_lib +{% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} +from {{package_path}}._compat import _observability 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 @@ -85,7 +86,7 @@ class _LoggingClientAIOInterceptor(grpc.aio.UnaryUnaryClientInterceptor): # pra grpc_response = { "payload": response_payload, "metadata": metadata, - "status": "OK", + "status": "OK", } _LOGGER.debug( f"Received response to rpc {client_call_details.method}.", @@ -171,6 +172,9 @@ class {{ service.grpc_asyncio_transport_name }}({{ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -192,7 +196,7 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): are passed to :func:`google.auth.default`. channel (Optional[Union[aio.Channel, Callable[..., aio.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. @@ -222,6 +226,11 @@ class {{ service.grpc_asyncio_transport_name }}({{ 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[aio.ClientInterceptor]]): + Additional 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 @@ -279,6 +288,8 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -300,10 +311,52 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -451,11 +504,9 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): {{ shared_macros.prep_wrapped_messages_async_method(api, service)|indent(4) }} - {{ shared_macros.wrap_async_method_macro()|indent(4) }} - def close(self): return self._logged_channel.close() - + @property def kind(self) -> str: return "grpc_asyncio" @@ -466,4 +517,4 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): __all__ = ( '{{ service.name }}GrpcAsyncIOTransport', ) -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 index 1bc499c068ee..df6b27b89f94 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 @@ -3,6 +3,7 @@ {% block content %} +import contextlib import logging import json # type: ignore @@ -14,7 +15,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} -from {{package_path}}._compat import transcode_request +from {{package_path}}._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -36,6 +37,7 @@ import warnings {{ shared_macros.operations_mixin_imports(api, service, opts) }} +from google.api_core import client_options as client_options_lib from .rest_base import _Base{{ service.name }}RestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -69,6 +71,7 @@ class {{service.name}}RestStub: _session: AuthorizedSession _host: str _interceptor: {{ service.name }}RestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): @@ -97,6 +100,8 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): url_scheme: str = 'https', interceptor: Optional[{{ service.name }}RestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -142,6 +147,9 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): 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. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -153,7 +161,9 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -266,7 +276,7 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) {% endif %}{# method.lro #} - {#- TODO(https://github.com/googleapis/gapic-generator-python/issues/2274): Add debug log before intercepting a request #} + {#- TODO(https://github.com/googleapis/gapic-generator-python/issues/2274): Add debug log before intercepting a request #} resp = self._interceptor.post_{{ method.name|snake_case }}(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_{{ method.name|snake_case }}_with_metadata(resp, response_metadata) @@ -318,7 +328,7 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): {{method.output.ident}}]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._{{method.name}}(self._session, self._host, self._interceptor) # type: ignore + return self._{{method.name}}(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore {% endfor %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 index 0f79d6e1ffef..742945dc8a84 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 @@ -35,7 +35,7 @@ from google.api_core import retry_async as retries from google.api_core import rest_helpers from google.api_core import rest_streaming_async # type: ignore {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} -from {{package_path}}._compat import transcode_request +from {{package_path}}._compat import transcode_request, trace_http_request, record_http_response import google.protobuf @@ -51,17 +51,20 @@ from google.iam.v1 import policy_pb2 # type: ignore from google.cloud.location import locations_pb2 # type: ignore {% endif %} +import contextlib import json # type: ignore import dataclasses from typing import Any, Dict, List, Callable, Tuple, Optional, Sequence, Union {{ shared_macros.operations_mixin_imports(api, service, opts) }} +from google.api_core import client_options as client_options_lib from .rest_base import _Base{{ service.name }}RestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO +import asyncio import logging try: @@ -92,6 +95,7 @@ class Async{{service.name}}RestStub: _session: AsyncAuthorizedSession _host: str _interceptor: Async{{service.name}}RestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): """Asynchronous REST backend transport for {{ service.name }}. @@ -111,6 +115,8 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[Async{{ service.name }}RestInterceptor] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -138,6 +144,9 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): "http" can be specified. interceptor (Optional[Async{{ service.name }}RestInterceptor]): Interceptor used to manipulate requests, request metadata, and responses. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor super().__init__( @@ -146,7 +155,9 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): client_info=client_info, always_use_jwt_access=False, url_scheme=url_scheme, - api_audience=None + api_audience=None, + client_options=client_options, + **kwargs, ) {# Note: Type for creds is ignored because of incorrect type hint for creds in the client layer. # TODO(https://github.com/googleapis/gapic-generator-python/issues/2177): Remove `# type: ignore` once @@ -154,7 +165,6 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): #} self._session = AsyncAuthorizedSession(self._credentials) # type: ignore self._interceptor = interceptor or Async{{ service.name }}RestInterceptor() - self._wrap_with_kind = True self._prep_wrapped_messages(client_info) {% if service.has_lro %} self._operations_client: Optional[operations_v1.AsyncOperationsRestClient] = None @@ -163,7 +173,6 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): {{ shared_macros.prep_wrapped_messages_async_method(api, service)|indent(4) }} - {{ shared_macros.wrap_async_method_macro()|indent(4) }} {% for method in service.methods.values()|sort(attribute="name") %} class {{ method.name|make_private }}(_Base{{ service.name }}RestTransport._Base{{method.name}}, Async{{service.name}}RestStub): @@ -260,7 +269,7 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): return resp {% endif %}{# method.void #} - + {% else %} raise NotImplementedError( "Method {{ method.name }} is not available over REST transport" @@ -322,10 +331,10 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): def {{method.transport_safe_name|snake_case}}(self) -> Callable[ [{{method.input.ident}}], {{method.output.ident}}]: - return self._{{method.name}}(self._session, self._host, self._interceptor) # type: ignore + return self._{{method.name}}(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore {% endfor %} - {% for name, sig in api.mixin_api_signatures.items() %} + {% for name, sig in api.mixin_api_signatures.items() %} {{ shared_macros.generate_mixin_call_method(service, api, name, sig, is_async=True) | indent(4) }} {% endfor %} @@ -336,4 +345,10 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): async def close(self): await self._session.close() + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + {% endblock %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 index 08887c16eb51..1b075b1feed0 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 @@ -21,6 +21,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format {% if opts.add_iam_methods or api.has_iam_mixin %} @@ -40,8 +41,8 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union class _Base{{ service.name }}RestTransport({{service.name}}Transport): """Base REST backend transport for {{ service.name }}. - - Note: This class is not meant to be used directly. Use its sync and + + Note: This class is not meant to be used directly. Use its sync and async sub-classes instead. This class defines the same methods as the primary client, so the @@ -60,13 +61,15 @@ class _Base{{ service.name }}RestTransport({{service.name}}Transport): always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: host ({% if service.host %}Optional[str]{% else %}str{% endif %}): {{ ' ' }}The hostname to connect to {% if service.host %}(default: '{{ service.host }}'){% endif %}. {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2173): Type hint for credentials is - # set to `Any` to support async and sync credential types in the parent rest transport classes. + # set to `Any` to support async and sync credential types in the parent rest transport classes. # However, we should have a stronger type here such as an abstract base credentials # class leveraged by sync and async credential classes. #} @@ -85,6 +88,9 @@ class _Base{{ service.name }}RestTransport({{service.name}}Transport): url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -100,7 +106,9 @@ class _Base{{ service.name }}RestTransport({{service.name}}Transport): credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) {% for method in service.methods.values()|sort(attribute="name") %} 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..3899a93329c4 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,194 @@ 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 + + +def test_{{ service.name|snake_case }}_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.{{ service.grpc_asyncio_transport_name }}, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.{{ service.grpc_asyncio_transport_name }}( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_{{ service.name|snake_case }}_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_asyncio_transport_name }}, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.{{ service.grpc_asyncio_transport_name }}( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_{{ service.name|snake_case }}_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.{{ service.grpc_asyncio_transport_name }}, + "create_channel", + ) as mock_create_channel: + transport = transports.{{ service.grpc_asyncio_transport_name }}( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -1209,13 +1397,7 @@ def test_{{ service.name|snake_case }}_base_transport(): transport.operations_client {% endif %} - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_{{ service.name|snake_case }}_base_transport_with_credentials_file(): @@ -1246,6 +1428,94 @@ 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 + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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 }}_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_compat.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_compat.py.j2 index 8d5744994305..f2cadd1f4129 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_compat.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_compat.py.j2 @@ -5,9 +5,9 @@ {% set has_auto_populated_fields = api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} """Tests for the compatibility module for older versions of google-api-core.""" {# TODO(https://github.com/googleapis/google-cloud-python/issues/17813): -Clean up this file/tests when the minimum supported version of +Clean up this file/tests when the minimum supported version of google-api-core has the functions in `_compat.py.j2`. #} -{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883): +{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883): Backfill compatibility functions tests being removed from the client layer. #} import json @@ -23,7 +23,16 @@ import google.auth.transport.mtls {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} from {{package_path}}._compat import transcode_request -from {{package_path}}._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from {{package_path}}._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) {% if has_auto_populated_fields %} from {{package_path}}._compat import setup_request_id {% endif %} @@ -531,4 +540,67 @@ def test_read_environment_variables(): with pytest.raises(MutualTLSChannelError): read_environment_variables() + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from {{package_path}} import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from {{package_path}} import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + {% endblock %} diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 index 05c60cbdc9c3..f38588637879 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 @@ -1230,7 +1230,7 @@ def test_{{ method_name }}_rest_required_fields(request_type={{ method.input.ide if key == "{{ auto_populated_field|camel_case }}": assert _UUID4_RE.match(value) break - + # Include {{ auto_populated_field|camel_case }} within expected_params with value mock.ANY expected_params = [p for p in expected_params if p[0] != "{{ auto_populated_field|camel_case }}"] expected_params.append( @@ -1670,6 +1670,13 @@ def test_{{ service.name|snake_case }}_{{ transport_name }}_lro_client(): close.assert_not_called() close.assert_called_once() + {% if transport_name == 'rest_asyncio' %} + with mock.patch.object(type(getattr(client.transport, "{{close_session[transport]}}")), "close") as close: + async with client.transport: + close.assert_not_called() + close.assert_called_once() + {% endif %} + {% endmacro %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2121): Remove / Update this test macro when async rest is GA. #} @@ -1684,7 +1691,13 @@ def test_unsupported_parameter_rest_asyncio(): credentials={{get_credentials(True)}}, transport="rest_asyncio", client_options=options - ) + ) + with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError, match="google.api_core.client_options.ClientOptions.quota_project_id") as exc: # type: ignore + client = {{ service.client_name }}( + credentials={{get_credentials(False)}}, + transport="rest_asyncio", + client_options=options + ) {% endmacro %} @@ -1727,7 +1740,7 @@ def test_unsupported_parameter_rest_asyncio(): # TODO(https://github.com/googleapis/gapic-generator-python/issues/2142): Continue migrating the test cases # in macro::run_transport_tests_for_config into here, and then delete that macro in favor of this one. # TODO(https://github.com/googleapis/gapic-generator-python/issues/2153): As a follow up, migrate gRPC test cases -# into `run_transport_tests_for_config` and make any of the rest specific specific macros which are called within more generic. +# into `run_transport_tests_for_config` and make any of the rest specific specific macros which are called within more generic. #} {% macro run_transport_tests_for_config(service, api, transport, is_async) %} {% for method in service.methods.values() %} @@ -1783,7 +1796,7 @@ def test_unsupported_parameter_rest_asyncio(): {% endmacro %} {# initialize_client_with_transport_test adds coverage for transport clients. - # Note: This test case is needed because we aren't unconditionally + # Note: This test case is needed because we aren't unconditionally # generating the not implemented coverage test for every client. #} {% macro initialize_client_with_transport_test(service, transport, is_async) %} @@ -2086,7 +2099,7 @@ def test_initialize_client_w_{{transport_name}}(): assert response.raw_page is response {% endif %} - + {% if method.server_streaming %} {% if is_async %} assert isinstance(response, AsyncIterable) @@ -2096,7 +2109,7 @@ def test_initialize_client_w_{{transport_name}}(): response = next(response) {% endif %} {% endif %} - + # Establish that the response is the type that we expect. {% if method.void %} assert response is None diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index a9724ae3b450..50b41c643aad 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 @@ -354,6 +354,7 @@ def showcase_library( f"google/showcase/v1beta1/echo.proto", f"google/showcase/v1beta1/identity.proto", f"google/showcase/v1beta1/messaging.proto", + f"google/showcase/v1beta1/sequence.proto", ) session.run( *cmd_tup, @@ -404,14 +405,23 @@ def showcase( """Run the Showcase test suite.""" with showcase_library(session, templates=templates, other_opts=other_opts): + # In monorepo development, install the local google-api-core package from source + local_core = Path(__file__).resolve().parent.parent / "google-api-core" + if local_core.is_dir(): + session.install("-e", str(local_core)) + # 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 = [ "py.test", - "--quiet", *(session.posargs or [str(test_directory)]), ] if ignore_file: @@ -439,6 +449,11 @@ def showcase_w_rest_async( # 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 +513,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 +529,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/_compat.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..ac1340303ed8 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 @@ -27,7 +27,14 @@ 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.cloud.asset_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.asset_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -545,18 +552,29 @@ 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) + ): + 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..6659d8d43bf2 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class AssetServiceTransport(abc.ABC): """Abstract transport class for AssetService.""" @@ -55,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. @@ -85,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. @@ -122,26 +138,71 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +216,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 +238,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 +254,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 +276,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 +292,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 +308,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 +323,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", ), } @@ -554,7 +637,7 @@ def get_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..30d292c5823c 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,20 @@ 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 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 +from google.cloud.asset_v1._compat import _observability 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 @@ -36,6 +38,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import AssetServiceTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -132,6 +139,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 +198,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 +260,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 +282,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/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py index 8fb1179f2fde..34df8a3e8859 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.asset_v1._compat import _observability 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 @@ -179,6 +180,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -230,6 +234,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -285,6 +294,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -306,10 +317,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -1054,19 +1107,24 @@ def analyze_org_policy_governed_assets(self) -> Callable[ return self._stubs['analyze_org_policy_governed_assets'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.export_assets: self._wrap_method( + self.export_assets: self._wrap_async_method( self.export_assets, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ExportAssets", ), - self.list_assets: self._wrap_method( + self.list_assets: self._wrap_async_method( self.list_assets, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ListAssets", ), - self.batch_get_assets_history: self._wrap_method( + self.batch_get_assets_history: self._wrap_async_method( self.batch_get_assets_history, default_retry=retries.AsyncRetry( initial=0.1, @@ -1080,13 +1138,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: self._wrap_method( + self.create_feed: self._wrap_async_method( self.create_feed, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/CreateFeed", ), - self.get_feed: self._wrap_method( + self.get_feed: self._wrap_async_method( self.get_feed, default_retry=retries.AsyncRetry( initial=0.1, @@ -1100,8 +1160,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: self._wrap_method( + self.list_feeds: self._wrap_async_method( self.list_feeds, default_retry=retries.AsyncRetry( initial=0.1, @@ -1115,13 +1176,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: self._wrap_method( + self.update_feed: self._wrap_async_method( self.update_feed, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/UpdateFeed", ), - self.delete_feed: self._wrap_method( + self.delete_feed: self._wrap_async_method( self.delete_feed, default_retry=retries.AsyncRetry( initial=0.1, @@ -1135,8 +1198,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: self._wrap_method( + self.search_all_resources: self._wrap_async_method( self.search_all_resources, default_retry=retries.AsyncRetry( initial=0.1, @@ -1150,8 +1214,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: self._wrap_method( + self.search_all_iam_policies: self._wrap_async_method( self.search_all_iam_policies, default_retry=retries.AsyncRetry( initial=0.1, @@ -1165,8 +1230,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: self._wrap_method( + self.analyze_iam_policy: self._wrap_async_method( self.analyze_iam_policy, default_retry=retries.AsyncRetry( initial=0.1, @@ -1179,79 +1245,88 @@ 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: self._wrap_method( + self.analyze_iam_policy_longrunning: self._wrap_async_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: self._wrap_method( + self.analyze_move: self._wrap_async_method( self.analyze_move, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/AnalyzeMove", ), - self.query_assets: self._wrap_method( + self.query_assets: self._wrap_async_method( self.query_assets, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/QueryAssets", ), - self.create_saved_query: self._wrap_method( + self.create_saved_query: self._wrap_async_method( self.create_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/CreateSavedQuery", ), - self.get_saved_query: self._wrap_method( + self.get_saved_query: self._wrap_async_method( self.get_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/GetSavedQuery", ), - self.list_saved_queries: self._wrap_method( + self.list_saved_queries: self._wrap_async_method( self.list_saved_queries, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/ListSavedQueries", ), - self.update_saved_query: self._wrap_method( + self.update_saved_query: self._wrap_async_method( self.update_saved_query, default_timeout=None, client_info=client_info, + method_name="google.cloud.asset.v1.AssetService/UpdateSavedQuery", ), - self.delete_saved_query: self._wrap_method( + self.delete_saved_query: self._wrap_async_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: self._wrap_method( + self.batch_get_effective_iam_policies: self._wrap_async_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: self._wrap_method( + self.analyze_org_policies: self._wrap_async_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: self._wrap_method( + self.analyze_org_policy_governed_containers: self._wrap_async_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: self._wrap_method( + self.analyze_org_policy_governed_assets: self._wrap_async_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: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest.py index d85aa16473c2..1cec80206b09 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.cloud.asset_v1._compat import transcode_request +from google.cloud.asset_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -40,6 +41,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseAssetServiceRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -1060,6 +1062,7 @@ class AssetServiceRestStub: _session: AuthorizedSession _host: str _interceptor: AssetServiceRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class AssetServiceRestTransport(_BaseAssetServiceRestTransport): @@ -1087,6 +1090,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[AssetServiceRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -1130,6 +1135,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -1141,7 +1149,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -1194,19 +1204,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeIamPolicyRequest, *, @@ -1272,7 +1296,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._AnalyzeIamPolicy._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1284,7 +1316,6 @@ def __call__(self, pb_resp = asset_service.AnalyzeIamPolicyResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_iam_policy(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_iam_policy_with_metadata(resp, response_metadata) @@ -1321,20 +1352,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeIamPolicyLongrunningRequest, *, @@ -1402,7 +1447,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeIamPolicyLongrunning._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._AnalyzeIamPolicyLongrunning._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1412,7 +1466,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_iam_policy_longrunning(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_iam_policy_longrunning_with_metadata(resp, response_metadata) @@ -1449,19 +1502,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeMoveRequest, *, @@ -1527,7 +1594,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeMove._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._AnalyzeMove._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1539,7 +1614,6 @@ def __call__(self, pb_resp = asset_service.AnalyzeMoveResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_move(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_move_with_metadata(resp, response_metadata) @@ -1576,19 +1650,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeOrgPoliciesRequest, *, @@ -1654,7 +1742,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeOrgPolicies._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._AnalyzeOrgPolicies._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1666,7 +1762,6 @@ def __call__(self, pb_resp = asset_service.AnalyzeOrgPoliciesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_org_policies(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_org_policies_with_metadata(resp, response_metadata) @@ -1703,19 +1798,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeOrgPolicyGovernedAssetsRequest, *, @@ -1782,7 +1891,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeOrgPolicyGovernedAssets._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._AnalyzeOrgPolicyGovernedAssets._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1794,7 +1911,6 @@ def __call__(self, pb_resp = asset_service.AnalyzeOrgPolicyGovernedAssetsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_org_policy_governed_assets(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_org_policy_governed_assets_with_metadata(resp, response_metadata) @@ -1831,19 +1947,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.AnalyzeOrgPolicyGovernedContainersRequest, *, @@ -1910,7 +2040,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._AnalyzeOrgPolicyGovernedContainers._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._AnalyzeOrgPolicyGovernedContainers._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1922,7 +2060,6 @@ def __call__(self, pb_resp = asset_service.AnalyzeOrgPolicyGovernedContainersResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_analyze_org_policy_governed_containers(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_analyze_org_policy_governed_containers_with_metadata(resp, response_metadata) @@ -1959,19 +2096,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.BatchGetAssetsHistoryRequest, *, @@ -2034,7 +2185,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._BatchGetAssetsHistory._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._BatchGetAssetsHistory._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2046,7 +2205,6 @@ def __call__(self, pb_resp = asset_service.BatchGetAssetsHistoryResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_batch_get_assets_history(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_batch_get_assets_history_with_metadata(resp, response_metadata) @@ -2083,19 +2241,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.BatchGetEffectiveIamPoliciesRequest, *, @@ -2162,7 +2334,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._BatchGetEffectiveIamPolicies._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._BatchGetEffectiveIamPolicies._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2174,7 +2354,6 @@ def __call__(self, pb_resp = asset_service.BatchGetEffectiveIamPoliciesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_batch_get_effective_iam_policies(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_batch_get_effective_iam_policies_with_metadata(resp, response_metadata) @@ -2211,20 +2390,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.CreateFeedRequest, *, @@ -2295,7 +2488,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._CreateFeed._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._CreateFeed._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2307,7 +2509,6 @@ def __call__(self, pb_resp = asset_service.Feed.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_feed(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_feed_with_metadata(resp, response_metadata) @@ -2344,20 +2545,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.CreateSavedQueryRequest, *, @@ -2422,7 +2637,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._CreateSavedQuery._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._CreateSavedQuery._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2434,7 +2658,6 @@ def __call__(self, pb_resp = asset_service.SavedQuery.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_saved_query(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_saved_query_with_metadata(resp, response_metadata) @@ -2471,19 +2694,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.DeleteFeedRequest, *, @@ -2542,7 +2779,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._DeleteFeed._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._DeleteFeed._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2561,19 +2806,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.DeleteSavedQueryRequest, *, @@ -2632,7 +2891,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._DeleteSavedQuery._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._DeleteSavedQuery._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2651,20 +2918,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.ExportAssetsRequest, *, @@ -2730,7 +3011,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._ExportAssets._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._ExportAssets._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2740,7 +3030,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_export_assets(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_export_assets_with_metadata(resp, response_metadata) @@ -2777,19 +3066,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.GetFeedRequest, *, @@ -2860,7 +3163,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._GetFeed._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._GetFeed._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2872,7 +3183,6 @@ def __call__(self, pb_resp = asset_service.Feed.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_feed(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_feed_with_metadata(resp, response_metadata) @@ -2909,19 +3219,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.GetSavedQueryRequest, *, @@ -2986,7 +3310,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._GetSavedQuery._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._GetSavedQuery._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2998,7 +3330,6 @@ def __call__(self, pb_resp = asset_service.SavedQuery.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_saved_query(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_saved_query_with_metadata(resp, response_metadata) @@ -3035,19 +3366,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.ListAssetsRequest, *, @@ -3110,7 +3455,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._ListAssets._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._ListAssets._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3122,7 +3475,6 @@ def __call__(self, pb_resp = asset_service.ListAssetsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_assets(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_assets_with_metadata(resp, response_metadata) @@ -3159,19 +3511,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.ListFeedsRequest, *, @@ -3234,7 +3600,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._ListFeeds._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._ListFeeds._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3246,7 +3620,6 @@ def __call__(self, pb_resp = asset_service.ListFeedsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_feeds(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_feeds_with_metadata(resp, response_metadata) @@ -3283,19 +3656,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.ListSavedQueriesRequest, *, @@ -3358,7 +3745,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._ListSavedQueries._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._ListSavedQueries._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3370,7 +3765,6 @@ def __call__(self, pb_resp = asset_service.ListSavedQueriesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_saved_queries(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_saved_queries_with_metadata(resp, response_metadata) @@ -3407,20 +3801,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.QueryAssetsRequest, *, @@ -3483,7 +3891,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._QueryAssets._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._QueryAssets._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3495,7 +3912,6 @@ def __call__(self, pb_resp = asset_service.QueryAssetsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_query_assets(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_query_assets_with_metadata(resp, response_metadata) @@ -3532,19 +3948,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.SearchAllIamPoliciesRequest, *, @@ -3607,7 +4037,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._SearchAllIamPolicies._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._SearchAllIamPolicies._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3619,7 +4057,6 @@ def __call__(self, pb_resp = asset_service.SearchAllIamPoliciesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_search_all_iam_policies(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_search_all_iam_policies_with_metadata(resp, response_metadata) @@ -3656,19 +4093,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.SearchAllResourcesRequest, *, @@ -3731,7 +4182,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._SearchAllResources._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._SearchAllResources._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3743,7 +4202,6 @@ def __call__(self, pb_resp = asset_service.SearchAllResourcesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_search_all_resources(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_search_all_resources_with_metadata(resp, response_metadata) @@ -3780,20 +4238,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.UpdateFeedRequest, *, @@ -3864,7 +4336,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._UpdateFeed._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._UpdateFeed._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3876,7 +4357,6 @@ def __call__(self, pb_resp = asset_service.Feed.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_feed(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_feed_with_metadata(resp, response_metadata) @@ -3913,20 +4393,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: asset_service.UpdateSavedQueryRequest, *, @@ -3991,7 +4485,16 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._UpdateSavedQuery._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = AssetServiceRestTransport._UpdateSavedQuery._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4003,7 +4506,6 @@ def __call__(self, pb_resp = asset_service.SavedQuery.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_saved_query(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_saved_query_with_metadata(resp, response_metadata) @@ -4034,7 +4536,7 @@ def analyze_iam_policy(self) -> Callable[ asset_service.AnalyzeIamPolicyResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeIamPolicy(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeIamPolicy(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def analyze_iam_policy_longrunning(self) -> Callable[ @@ -4042,7 +4544,7 @@ def analyze_iam_policy_longrunning(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeIamPolicyLongrunning(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeIamPolicyLongrunning(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def analyze_move(self) -> Callable[ @@ -4050,7 +4552,7 @@ def analyze_move(self) -> Callable[ asset_service.AnalyzeMoveResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeMove(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeMove(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def analyze_org_policies(self) -> Callable[ @@ -4058,7 +4560,7 @@ def analyze_org_policies(self) -> Callable[ asset_service.AnalyzeOrgPoliciesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeOrgPolicies(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeOrgPolicies(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def analyze_org_policy_governed_assets(self) -> Callable[ @@ -4066,7 +4568,7 @@ def analyze_org_policy_governed_assets(self) -> Callable[ asset_service.AnalyzeOrgPolicyGovernedAssetsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeOrgPolicyGovernedAssets(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeOrgPolicyGovernedAssets(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def analyze_org_policy_governed_containers(self) -> Callable[ @@ -4074,7 +4576,7 @@ def analyze_org_policy_governed_containers(self) -> Callable[ asset_service.AnalyzeOrgPolicyGovernedContainersResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._AnalyzeOrgPolicyGovernedContainers(self._session, self._host, self._interceptor) # type: ignore + return self._AnalyzeOrgPolicyGovernedContainers(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def batch_get_assets_history(self) -> Callable[ @@ -4082,7 +4584,7 @@ def batch_get_assets_history(self) -> Callable[ asset_service.BatchGetAssetsHistoryResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._BatchGetAssetsHistory(self._session, self._host, self._interceptor) # type: ignore + return self._BatchGetAssetsHistory(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def batch_get_effective_iam_policies(self) -> Callable[ @@ -4090,7 +4592,7 @@ def batch_get_effective_iam_policies(self) -> Callable[ asset_service.BatchGetEffectiveIamPoliciesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._BatchGetEffectiveIamPolicies(self._session, self._host, self._interceptor) # type: ignore + return self._BatchGetEffectiveIamPolicies(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_feed(self) -> Callable[ @@ -4098,7 +4600,7 @@ def create_feed(self) -> Callable[ asset_service.Feed]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateFeed(self._session, self._host, self._interceptor) # type: ignore + return self._CreateFeed(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_saved_query(self) -> Callable[ @@ -4106,7 +4608,7 @@ def create_saved_query(self) -> Callable[ asset_service.SavedQuery]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateSavedQuery(self._session, self._host, self._interceptor) # type: ignore + return self._CreateSavedQuery(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_feed(self) -> Callable[ @@ -4114,7 +4616,7 @@ def delete_feed(self) -> Callable[ empty_pb2.Empty]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteFeed(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteFeed(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_saved_query(self) -> Callable[ @@ -4122,7 +4624,7 @@ def delete_saved_query(self) -> Callable[ empty_pb2.Empty]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteSavedQuery(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteSavedQuery(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def export_assets(self) -> Callable[ @@ -4130,7 +4632,7 @@ def export_assets(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ExportAssets(self._session, self._host, self._interceptor) # type: ignore + return self._ExportAssets(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_feed(self) -> Callable[ @@ -4138,7 +4640,7 @@ def get_feed(self) -> Callable[ asset_service.Feed]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetFeed(self._session, self._host, self._interceptor) # type: ignore + return self._GetFeed(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_saved_query(self) -> Callable[ @@ -4146,7 +4648,7 @@ def get_saved_query(self) -> Callable[ asset_service.SavedQuery]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetSavedQuery(self._session, self._host, self._interceptor) # type: ignore + return self._GetSavedQuery(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_assets(self) -> Callable[ @@ -4154,7 +4656,7 @@ def list_assets(self) -> Callable[ asset_service.ListAssetsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListAssets(self._session, self._host, self._interceptor) # type: ignore + return self._ListAssets(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_feeds(self) -> Callable[ @@ -4162,7 +4664,7 @@ def list_feeds(self) -> Callable[ asset_service.ListFeedsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListFeeds(self._session, self._host, self._interceptor) # type: ignore + return self._ListFeeds(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_saved_queries(self) -> Callable[ @@ -4170,7 +4672,7 @@ def list_saved_queries(self) -> Callable[ asset_service.ListSavedQueriesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListSavedQueries(self._session, self._host, self._interceptor) # type: ignore + return self._ListSavedQueries(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def query_assets(self) -> Callable[ @@ -4178,7 +4680,7 @@ def query_assets(self) -> Callable[ asset_service.QueryAssetsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._QueryAssets(self._session, self._host, self._interceptor) # type: ignore + return self._QueryAssets(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def search_all_iam_policies(self) -> Callable[ @@ -4186,7 +4688,7 @@ def search_all_iam_policies(self) -> Callable[ asset_service.SearchAllIamPoliciesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._SearchAllIamPolicies(self._session, self._host, self._interceptor) # type: ignore + return self._SearchAllIamPolicies(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def search_all_resources(self) -> Callable[ @@ -4194,7 +4696,7 @@ def search_all_resources(self) -> Callable[ asset_service.SearchAllResourcesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._SearchAllResources(self._session, self._host, self._interceptor) # type: ignore + return self._SearchAllResources(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_feed(self) -> Callable[ @@ -4202,7 +4704,7 @@ def update_feed(self) -> Callable[ asset_service.Feed]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateFeed(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateFeed(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_saved_query(self) -> Callable[ @@ -4210,11 +4712,11 @@ def update_saved_query(self) -> Callable[ asset_service.SavedQuery]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateSavedQuery(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateSavedQuery(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseAssetServiceRestTransport._BaseGetOperation, AssetServiceRestStub): def __hash__(self): @@ -4228,19 +4730,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -4303,7 +4819,15 @@ def __call__(self, ) # Send the request - response = AssetServiceRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = AssetServiceRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest_base.py index b5b30671d1fb..d79cf2f07000 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from .base import AssetServiceTransport, DEFAULT_CLIENT_INFO @@ -49,6 +50,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -69,6 +72,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -84,7 +90,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseAnalyzeIamPolicy: 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..1e06fe4114a4 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,194 @@ 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 + + +def test_asset_service_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.AssetServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.AssetServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_asset_service_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.AssetServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_asset_service_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.AssetServiceGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.AssetServiceGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -17299,13 +17487,7 @@ def test_asset_service_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_asset_service_base_transport_with_credentials_file(): @@ -17335,6 +17517,94 @@ 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 + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/asset/tests/unit/gapic/asset_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_compat.py index df64f9d08916..3419cb522117 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.asset_v1._compat import transcode_request -from google.cloud.asset_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.asset_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.asset_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.asset_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..8af747ffbd1f 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 @@ -27,7 +27,14 @@ 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.iam.credentials_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.iam.credentials_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -482,18 +489,29 @@ 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) + ): + 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..02746fc17d7b 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class IAMCredentialsTransport(abc.ABC): """Abstract transport class for IAMCredentials.""" @@ -52,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. @@ -82,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. @@ -119,16 +135,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +201,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 +217,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 +233,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 +249,7 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/SignJwt", ), } @@ -237,7 +300,7 @@ def sign_jwt(self) -> Callable[ @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..b866f029544c 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,22 +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 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +from google.iam.credentials_v1._compat import _observability 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 from .base import IAMCredentialsTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -138,6 +145,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 +204,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 +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: @@ -264,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/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py index d9d401f8d9f1..77546a5bbddb 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -24,6 +23,8 @@ from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import client_options as client_options_lib +from google.iam.credentials_v1._compat import _observability 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 @@ -185,6 +186,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -236,6 +240,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -290,6 +299,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -311,10 +322,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -437,9 +490,12 @@ def sign_jwt(self) -> Callable[ return self._stubs['sign_jwt'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.generate_access_token: self._wrap_method( + self.generate_access_token: self._wrap_async_method( self.generate_access_token, default_retry=retries.AsyncRetry( initial=0.1, @@ -453,8 +509,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: self._wrap_method( + self.generate_id_token: self._wrap_async_method( self.generate_id_token, default_retry=retries.AsyncRetry( initial=0.1, @@ -468,8 +525,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: self._wrap_method( + self.sign_blob: self._wrap_async_method( self.sign_blob, default_retry=retries.AsyncRetry( initial=0.1, @@ -483,8 +541,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: self._wrap_method( + self.sign_jwt: self._wrap_async_method( self.sign_jwt, default_retry=retries.AsyncRetry( initial=0.1, @@ -498,14 +557,10 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=60.0, client_info=client_info, + method_name="google.iam.credentials.v1.IAMCredentials/SignJwt", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest.py index 0cffb09641ed..78b7f9e0ea6f 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.iam.credentials_v1._compat import transcode_request +from google.iam.credentials_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -37,6 +38,7 @@ from google.iam.credentials_v1.types import common +from google.api_core import client_options as client_options_lib from .rest_base import _BaseIAMCredentialsRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -264,6 +266,7 @@ class IAMCredentialsRestStub: _session: AuthorizedSession _host: str _interceptor: IAMCredentialsRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class IAMCredentialsRestTransport(_BaseIAMCredentialsRestTransport): @@ -300,6 +303,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[IAMCredentialsRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -343,6 +348,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -354,7 +362,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -375,20 +385,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: common.GenerateAccessTokenRequest, *, @@ -451,7 +475,16 @@ def __call__(self, ) # Send the request - response = IAMCredentialsRestTransport._GenerateAccessToken._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IAMCredentialsRestTransport._GenerateAccessToken._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -463,7 +496,6 @@ def __call__(self, pb_resp = common.GenerateAccessTokenResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_generate_access_token(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_generate_access_token_with_metadata(resp, response_metadata) @@ -500,20 +532,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: common.GenerateIdTokenRequest, *, @@ -576,7 +622,16 @@ def __call__(self, ) # Send the request - response = IAMCredentialsRestTransport._GenerateIdToken._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IAMCredentialsRestTransport._GenerateIdToken._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -588,7 +643,6 @@ def __call__(self, pb_resp = common.GenerateIdTokenResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_generate_id_token(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_generate_id_token_with_metadata(resp, response_metadata) @@ -625,20 +679,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: common.SignBlobRequest, *, @@ -701,7 +769,16 @@ def __call__(self, ) # Send the request - response = IAMCredentialsRestTransport._SignBlob._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IAMCredentialsRestTransport._SignBlob._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -713,7 +790,6 @@ def __call__(self, pb_resp = common.SignBlobResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_sign_blob(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_sign_blob_with_metadata(resp, response_metadata) @@ -750,20 +826,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: common.SignJwtRequest, *, @@ -826,7 +916,16 @@ def __call__(self, ) # Send the request - response = IAMCredentialsRestTransport._SignJwt._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IAMCredentialsRestTransport._SignJwt._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -838,7 +937,6 @@ def __call__(self, pb_resp = common.SignJwtResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_sign_jwt(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_sign_jwt_with_metadata(resp, response_metadata) @@ -869,7 +967,7 @@ def generate_access_token(self) -> Callable[ common.GenerateAccessTokenResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GenerateAccessToken(self._session, self._host, self._interceptor) # type: ignore + return self._GenerateAccessToken(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def generate_id_token(self) -> Callable[ @@ -877,7 +975,7 @@ def generate_id_token(self) -> Callable[ common.GenerateIdTokenResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GenerateIdToken(self._session, self._host, self._interceptor) # type: ignore + return self._GenerateIdToken(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def sign_blob(self) -> Callable[ @@ -885,7 +983,7 @@ def sign_blob(self) -> Callable[ common.SignBlobResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._SignBlob(self._session, self._host, self._interceptor) # type: ignore + return self._SignBlob(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def sign_jwt(self) -> Callable[ @@ -893,7 +991,7 @@ def sign_jwt(self) -> Callable[ common.SignJwtResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._SignJwt(self._session, self._host, self._interceptor) # type: ignore + return self._SignJwt(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def kind(self) -> str: diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest_base.py index 3c578f3f8485..3e11472082ab 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from .base import IAMCredentialsTransport, DEFAULT_CLIENT_INFO @@ -47,6 +48,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -67,6 +70,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -82,7 +88,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseGenerateAccessToken: diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_compat.py index 066f4505bdef..47b2409a80b6 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.iam.credentials_v1._compat import transcode_request -from google.iam.credentials_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.iam.credentials_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.iam.credentials_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.iam.credentials_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) 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..07e093134741 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,194 @@ 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 + + +def test_iam_credentials_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.IAMCredentialsGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.IAMCredentialsGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_iam_credentials_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.IAMCredentialsGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_iam_credentials_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.IAMCredentialsGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.IAMCredentialsGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -3713,13 +3901,7 @@ def test_iam_credentials_base_transport(): with pytest.raises(NotImplementedError): transport.close() - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_iam_credentials_base_transport_with_credentials_file(): @@ -3749,6 +3931,94 @@ 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 + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/_compat.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..2c91d9504343 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 @@ -27,7 +27,14 @@ 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.cloud.eventarc_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.eventarc_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -665,18 +672,29 @@ 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) + ): + 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..b6cdc897a9f8 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class EventarcTransport(abc.ABC): """Abstract transport class for Eventarc.""" @@ -67,6 +79,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 +110,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 +150,345 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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", ), } @@ -834,7 +941,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..b35aa562f067 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,20 @@ 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 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 +from google.cloud.eventarc_v1._compat import _observability 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 @@ -48,6 +50,11 @@ from google.longrunning import operations_pb2 # type: ignore from .base import EventarcTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -146,6 +153,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 +212,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 +274,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 +296,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/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py index 966a52b3d9dd..5777efa05e4d 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.eventarc_v1._compat import _observability 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 @@ -193,6 +194,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -244,6 +248,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -299,6 +308,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -320,10 +331,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -1377,255 +1430,301 @@ def delete_google_api_source(self) -> Callable[ return self._stubs['delete_google_api_source'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.get_trigger: self._wrap_method( + self.get_trigger: self._wrap_async_method( self.get_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetTrigger", ), - self.list_triggers: self._wrap_method( + self.list_triggers: self._wrap_async_method( self.list_triggers, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListTriggers", ), - self.create_trigger: self._wrap_method( + self.create_trigger: self._wrap_async_method( self.create_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateTrigger", ), - self.update_trigger: self._wrap_method( + self.update_trigger: self._wrap_async_method( self.update_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateTrigger", ), - self.delete_trigger: self._wrap_method( + self.delete_trigger: self._wrap_async_method( self.delete_trigger, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteTrigger", ), - self.get_channel: self._wrap_method( + self.get_channel: self._wrap_async_method( self.get_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetChannel", ), - self.list_channels: self._wrap_method( + self.list_channels: self._wrap_async_method( self.list_channels, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListChannels", ), - self.create_channel_: self._wrap_method( + self.create_channel_: self._wrap_async_method( self.create_channel_, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateChannel", ), - self.update_channel: self._wrap_method( + self.update_channel: self._wrap_async_method( self.update_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateChannel", ), - self.delete_channel: self._wrap_method( + self.delete_channel: self._wrap_async_method( self.delete_channel, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteChannel", ), - self.get_provider: self._wrap_method( + self.get_provider: self._wrap_async_method( self.get_provider, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetProvider", ), - self.list_providers: self._wrap_method( + self.list_providers: self._wrap_async_method( self.list_providers, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListProviders", ), - self.get_channel_connection: self._wrap_method( + self.get_channel_connection: self._wrap_async_method( self.get_channel_connection, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetChannelConnection", ), - self.list_channel_connections: self._wrap_method( + self.list_channel_connections: self._wrap_async_method( self.list_channel_connections, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListChannelConnections", ), - self.create_channel_connection: self._wrap_method( + self.create_channel_connection: self._wrap_async_method( self.create_channel_connection, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateChannelConnection", ), - self.delete_channel_connection: self._wrap_method( + self.delete_channel_connection: self._wrap_async_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: self._wrap_method( + self.get_google_channel_config: self._wrap_async_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: self._wrap_method( + self.update_google_channel_config: self._wrap_async_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: self._wrap_method( + self.get_message_bus: self._wrap_async_method( self.get_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetMessageBus", ), - self.list_message_buses: self._wrap_method( + self.list_message_buses: self._wrap_async_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: self._wrap_method( + self.list_message_bus_enrollments: self._wrap_async_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: self._wrap_method( + self.create_message_bus: self._wrap_async_method( self.create_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateMessageBus", ), - self.update_message_bus: self._wrap_method( + self.update_message_bus: self._wrap_async_method( self.update_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateMessageBus", ), - self.delete_message_bus: self._wrap_method( + self.delete_message_bus: self._wrap_async_method( self.delete_message_bus, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteMessageBus", ), - self.get_enrollment: self._wrap_method( + self.get_enrollment: self._wrap_async_method( self.get_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetEnrollment", ), - self.list_enrollments: self._wrap_method( + self.list_enrollments: self._wrap_async_method( self.list_enrollments, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListEnrollments", ), - self.create_enrollment: self._wrap_method( + self.create_enrollment: self._wrap_async_method( self.create_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreateEnrollment", ), - self.update_enrollment: self._wrap_method( + self.update_enrollment: self._wrap_async_method( self.update_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdateEnrollment", ), - self.delete_enrollment: self._wrap_method( + self.delete_enrollment: self._wrap_async_method( self.delete_enrollment, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteEnrollment", ), - self.get_pipeline: self._wrap_method( + self.get_pipeline: self._wrap_async_method( self.get_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/GetPipeline", ), - self.list_pipelines: self._wrap_method( + self.list_pipelines: self._wrap_async_method( self.list_pipelines, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/ListPipelines", ), - self.create_pipeline: self._wrap_method( + self.create_pipeline: self._wrap_async_method( self.create_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/CreatePipeline", ), - self.update_pipeline: self._wrap_method( + self.update_pipeline: self._wrap_async_method( self.update_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/UpdatePipeline", ), - self.delete_pipeline: self._wrap_method( + self.delete_pipeline: self._wrap_async_method( self.delete_pipeline, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeletePipeline", ), - self.get_google_api_source: self._wrap_method( + self.get_google_api_source: self._wrap_async_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: self._wrap_method( + self.list_google_api_sources: self._wrap_async_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: self._wrap_method( + self.create_google_api_source: self._wrap_async_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: self._wrap_method( + self.update_google_api_source: self._wrap_async_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: self._wrap_method( + self.delete_google_api_source: self._wrap_async_method( self.delete_google_api_source, default_timeout=None, client_info=client_info, + method_name="google.cloud.eventarc.v1.Eventarc/DeleteGoogleApiSource", ), - self.get_location: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.get_iam_policy: self._wrap_method( + self.get_iam_policy: self._wrap_async_method( self.get_iam_policy, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/GetIamPolicy", ), - self.set_iam_policy: self._wrap_method( + self.set_iam_policy: self._wrap_async_method( self.set_iam_policy, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/SetIamPolicy", ), - self.test_iam_permissions: self._wrap_method( + self.test_iam_permissions: self._wrap_async_method( self.test_iam_permissions, default_timeout=None, client_info=client_info, + method_name="google.iam.v1.IAMPolicy/TestIamPermissions", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest.py index 1565671cf8d4..b0653f046ff8 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.cloud.eventarc_v1._compat import transcode_request +from google.cloud.eventarc_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -52,6 +53,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseEventarcRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -2008,6 +2010,7 @@ class EventarcRestStub: _session: AuthorizedSession _host: str _interceptor: EventarcRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class EventarcRestTransport(_BaseEventarcRestTransport): @@ -2037,6 +2040,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[EventarcRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -2080,6 +2085,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -2091,7 +2099,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -2163,20 +2173,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateChannelRequest, *, @@ -2243,7 +2267,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateChannel._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateChannel._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2253,7 +2286,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_channel(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_channel_with_metadata(resp, response_metadata) @@ -2290,20 +2322,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateChannelConnectionRequest, *, @@ -2370,7 +2416,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateChannelConnection._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateChannelConnection._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2380,7 +2435,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_channel_connection(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_channel_connection_with_metadata(resp, response_metadata) @@ -2417,20 +2471,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateEnrollmentRequest, *, @@ -2497,7 +2565,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateEnrollment._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateEnrollment._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2507,7 +2584,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_enrollment(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_enrollment_with_metadata(resp, response_metadata) @@ -2544,20 +2620,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateGoogleApiSourceRequest, *, @@ -2624,7 +2714,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateGoogleApiSource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateGoogleApiSource._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2634,7 +2733,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_google_api_source(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_google_api_source_with_metadata(resp, response_metadata) @@ -2671,20 +2769,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateMessageBusRequest, *, @@ -2751,7 +2863,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateMessageBus._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateMessageBus._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2761,7 +2882,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_message_bus(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_message_bus_with_metadata(resp, response_metadata) @@ -2798,20 +2918,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreatePipelineRequest, *, @@ -2878,7 +3012,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreatePipeline._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreatePipeline._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2888,7 +3031,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_pipeline(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_pipeline_with_metadata(resp, response_metadata) @@ -2925,20 +3067,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.CreateTriggerRequest, *, @@ -3005,7 +3161,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CreateTrigger._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CreateTrigger._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3015,7 +3180,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_trigger(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_trigger_with_metadata(resp, response_metadata) @@ -3052,19 +3216,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteChannelRequest, *, @@ -3131,7 +3309,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteChannel._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteChannel._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3141,7 +3327,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_channel(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_channel_with_metadata(resp, response_metadata) @@ -3178,19 +3363,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteChannelConnectionRequest, *, @@ -3257,7 +3456,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteChannelConnection._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteChannelConnection._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3267,7 +3474,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_channel_connection(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_channel_connection_with_metadata(resp, response_metadata) @@ -3304,19 +3510,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteEnrollmentRequest, *, @@ -3383,7 +3603,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteEnrollment._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteEnrollment._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3393,7 +3621,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_enrollment(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_enrollment_with_metadata(resp, response_metadata) @@ -3430,19 +3657,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteGoogleApiSourceRequest, *, @@ -3509,7 +3750,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteGoogleApiSource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteGoogleApiSource._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3519,7 +3768,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_google_api_source(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_google_api_source_with_metadata(resp, response_metadata) @@ -3556,19 +3804,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteMessageBusRequest, *, @@ -3635,7 +3897,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteMessageBus._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteMessageBus._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3645,7 +3915,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_message_bus(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_message_bus_with_metadata(resp, response_metadata) @@ -3682,19 +3951,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeletePipelineRequest, *, @@ -3761,7 +4044,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeletePipeline._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeletePipeline._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3771,7 +4062,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_pipeline(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_pipeline_with_metadata(resp, response_metadata) @@ -3808,19 +4098,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.DeleteTriggerRequest, *, @@ -3887,7 +4191,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteTrigger._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteTrigger._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3897,7 +4209,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_trigger(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_trigger_with_metadata(resp, response_metadata) @@ -3934,19 +4245,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetChannelRequest, *, @@ -4018,7 +4343,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetChannel._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetChannel._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4030,7 +4363,6 @@ def __call__(self, pb_resp = channel.Channel.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_channel(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_channel_with_metadata(resp, response_metadata) @@ -4067,19 +4399,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetChannelConnectionRequest, *, @@ -4150,7 +4496,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetChannelConnection._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetChannelConnection._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4162,7 +4516,6 @@ def __call__(self, pb_resp = channel_connection.ChannelConnection.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_channel_connection(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_channel_connection_with_metadata(resp, response_metadata) @@ -4199,19 +4552,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetEnrollmentRequest, *, @@ -4281,7 +4648,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetEnrollment._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetEnrollment._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4293,7 +4668,6 @@ def __call__(self, pb_resp = enrollment.Enrollment.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_enrollment(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_enrollment_with_metadata(resp, response_metadata) @@ -4330,19 +4704,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetGoogleApiSourceRequest, *, @@ -4409,7 +4797,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetGoogleApiSource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetGoogleApiSource._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4421,7 +4817,6 @@ def __call__(self, pb_resp = google_api_source.GoogleApiSource.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_google_api_source(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_google_api_source_with_metadata(resp, response_metadata) @@ -4458,19 +4853,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetGoogleChannelConfigRequest, *, @@ -4542,7 +4951,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetGoogleChannelConfig._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetGoogleChannelConfig._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4554,7 +4971,6 @@ def __call__(self, pb_resp = google_channel_config.GoogleChannelConfig.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_google_channel_config(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_google_channel_config_with_metadata(resp, response_metadata) @@ -4591,19 +5007,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetMessageBusRequest, *, @@ -4675,7 +5105,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetMessageBus._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetMessageBus._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4687,7 +5125,6 @@ def __call__(self, pb_resp = message_bus.MessageBus.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_message_bus(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_message_bus_with_metadata(resp, response_metadata) @@ -4724,19 +5161,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetPipelineRequest, *, @@ -4802,7 +5253,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetPipeline._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetPipeline._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4814,7 +5273,6 @@ def __call__(self, pb_resp = pipeline.Pipeline.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_pipeline(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_pipeline_with_metadata(resp, response_metadata) @@ -4851,19 +5309,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetProviderRequest, *, @@ -4929,7 +5401,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetProvider._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetProvider._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -4941,7 +5421,6 @@ def __call__(self, pb_resp = discovery.Provider.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_provider(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_provider_with_metadata(resp, response_metadata) @@ -4978,19 +5457,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.GetTriggerRequest, *, @@ -5056,7 +5549,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetTrigger._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetTrigger._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5068,7 +5569,6 @@ def __call__(self, pb_resp = trigger.Trigger.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_trigger(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_trigger_with_metadata(resp, response_metadata) @@ -5105,19 +5605,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListChannelConnectionsRequest, *, @@ -5183,7 +5697,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListChannelConnections._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListChannelConnections._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5195,7 +5717,6 @@ def __call__(self, pb_resp = eventarc.ListChannelConnectionsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_channel_connections(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_channel_connections_with_metadata(resp, response_metadata) @@ -5232,19 +5753,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListChannelsRequest, *, @@ -5308,7 +5843,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListChannels._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListChannels._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5320,7 +5863,6 @@ def __call__(self, pb_resp = eventarc.ListChannelsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_channels(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_channels_with_metadata(resp, response_metadata) @@ -5357,19 +5899,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListEnrollmentsRequest, *, @@ -5433,7 +5989,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListEnrollments._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListEnrollments._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5445,7 +6009,6 @@ def __call__(self, pb_resp = eventarc.ListEnrollmentsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_enrollments(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_enrollments_with_metadata(resp, response_metadata) @@ -5482,19 +6045,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListGoogleApiSourcesRequest, *, @@ -5560,7 +6137,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListGoogleApiSources._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListGoogleApiSources._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5572,7 +6157,6 @@ def __call__(self, pb_resp = eventarc.ListGoogleApiSourcesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_google_api_sources(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_google_api_sources_with_metadata(resp, response_metadata) @@ -5609,19 +6193,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListMessageBusEnrollmentsRequest, *, @@ -5688,7 +6286,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListMessageBusEnrollments._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListMessageBusEnrollments._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5700,7 +6306,6 @@ def __call__(self, pb_resp = eventarc.ListMessageBusEnrollmentsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_message_bus_enrollments(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_message_bus_enrollments_with_metadata(resp, response_metadata) @@ -5737,19 +6342,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListMessageBusesRequest, *, @@ -5815,7 +6434,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListMessageBuses._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListMessageBuses._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5827,7 +6454,6 @@ def __call__(self, pb_resp = eventarc.ListMessageBusesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_message_buses(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_message_buses_with_metadata(resp, response_metadata) @@ -5864,19 +6490,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListPipelinesRequest, *, @@ -5942,7 +6582,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListPipelines._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListPipelines._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -5954,7 +6602,6 @@ def __call__(self, pb_resp = eventarc.ListPipelinesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_pipelines(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_pipelines_with_metadata(resp, response_metadata) @@ -5991,19 +6638,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListProvidersRequest, *, @@ -6067,7 +6728,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListProviders._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListProviders._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6079,7 +6748,6 @@ def __call__(self, pb_resp = eventarc.ListProvidersResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_providers(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_providers_with_metadata(resp, response_metadata) @@ -6116,19 +6784,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.ListTriggersRequest, *, @@ -6192,7 +6874,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListTriggers._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListTriggers._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6204,7 +6894,6 @@ def __call__(self, pb_resp = eventarc.ListTriggersResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_triggers(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_triggers_with_metadata(resp, response_metadata) @@ -6241,20 +6930,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateChannelRequest, *, @@ -6321,7 +7024,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateChannel._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateChannel._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6331,7 +7043,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_channel(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_channel_with_metadata(resp, response_metadata) @@ -6368,20 +7079,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateEnrollmentRequest, *, @@ -6448,7 +7173,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateEnrollment._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateEnrollment._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6458,7 +7192,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_enrollment(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_enrollment_with_metadata(resp, response_metadata) @@ -6495,20 +7228,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateGoogleApiSourceRequest, *, @@ -6575,7 +7322,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateGoogleApiSource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateGoogleApiSource._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6585,7 +7341,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_google_api_source(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_google_api_source_with_metadata(resp, response_metadata) @@ -6622,20 +7377,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateGoogleChannelConfigRequest, *, @@ -6708,7 +7477,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateGoogleChannelConfig._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateGoogleChannelConfig._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6720,7 +7498,6 @@ def __call__(self, pb_resp = gce_google_channel_config.GoogleChannelConfig.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_google_channel_config(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_google_channel_config_with_metadata(resp, response_metadata) @@ -6757,20 +7534,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateMessageBusRequest, *, @@ -6837,7 +7628,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateMessageBus._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateMessageBus._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6847,7 +7647,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_message_bus(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_message_bus_with_metadata(resp, response_metadata) @@ -6884,20 +7683,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdatePipelineRequest, *, @@ -6964,7 +7777,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdatePipeline._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdatePipeline._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -6974,7 +7796,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_pipeline(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_pipeline_with_metadata(resp, response_metadata) @@ -7011,20 +7832,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: eventarc.UpdateTriggerRequest, *, @@ -7091,7 +7926,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._UpdateTrigger._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._UpdateTrigger._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -7101,7 +7945,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_trigger(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_trigger_with_metadata(resp, response_metadata) @@ -7132,7 +7975,7 @@ def create_channel_(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateChannel(self._session, self._host, self._interceptor) # type: ignore + return self._CreateChannel(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_channel_connection(self) -> Callable[ @@ -7140,7 +7983,7 @@ def create_channel_connection(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateChannelConnection(self._session, self._host, self._interceptor) # type: ignore + return self._CreateChannelConnection(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_enrollment(self) -> Callable[ @@ -7148,7 +7991,7 @@ def create_enrollment(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateEnrollment(self._session, self._host, self._interceptor) # type: ignore + return self._CreateEnrollment(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_google_api_source(self) -> Callable[ @@ -7156,7 +7999,7 @@ def create_google_api_source(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateGoogleApiSource(self._session, self._host, self._interceptor) # type: ignore + return self._CreateGoogleApiSource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_message_bus(self) -> Callable[ @@ -7164,7 +8007,7 @@ def create_message_bus(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateMessageBus(self._session, self._host, self._interceptor) # type: ignore + return self._CreateMessageBus(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_pipeline(self) -> Callable[ @@ -7172,7 +8015,7 @@ def create_pipeline(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreatePipeline(self._session, self._host, self._interceptor) # type: ignore + return self._CreatePipeline(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_trigger(self) -> Callable[ @@ -7180,7 +8023,7 @@ def create_trigger(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateTrigger(self._session, self._host, self._interceptor) # type: ignore + return self._CreateTrigger(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_channel(self) -> Callable[ @@ -7188,7 +8031,7 @@ def delete_channel(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteChannel(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteChannel(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_channel_connection(self) -> Callable[ @@ -7196,7 +8039,7 @@ def delete_channel_connection(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteChannelConnection(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteChannelConnection(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_enrollment(self) -> Callable[ @@ -7204,7 +8047,7 @@ def delete_enrollment(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteEnrollment(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteEnrollment(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_google_api_source(self) -> Callable[ @@ -7212,7 +8055,7 @@ def delete_google_api_source(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteGoogleApiSource(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteGoogleApiSource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_message_bus(self) -> Callable[ @@ -7220,7 +8063,7 @@ def delete_message_bus(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteMessageBus(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteMessageBus(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_pipeline(self) -> Callable[ @@ -7228,7 +8071,7 @@ def delete_pipeline(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeletePipeline(self._session, self._host, self._interceptor) # type: ignore + return self._DeletePipeline(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_trigger(self) -> Callable[ @@ -7236,7 +8079,7 @@ def delete_trigger(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteTrigger(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteTrigger(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_channel(self) -> Callable[ @@ -7244,7 +8087,7 @@ def get_channel(self) -> Callable[ channel.Channel]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetChannel(self._session, self._host, self._interceptor) # type: ignore + return self._GetChannel(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_channel_connection(self) -> Callable[ @@ -7252,7 +8095,7 @@ def get_channel_connection(self) -> Callable[ channel_connection.ChannelConnection]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetChannelConnection(self._session, self._host, self._interceptor) # type: ignore + return self._GetChannelConnection(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_enrollment(self) -> Callable[ @@ -7260,7 +8103,7 @@ def get_enrollment(self) -> Callable[ enrollment.Enrollment]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetEnrollment(self._session, self._host, self._interceptor) # type: ignore + return self._GetEnrollment(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_google_api_source(self) -> Callable[ @@ -7268,7 +8111,7 @@ def get_google_api_source(self) -> Callable[ google_api_source.GoogleApiSource]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetGoogleApiSource(self._session, self._host, self._interceptor) # type: ignore + return self._GetGoogleApiSource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_google_channel_config(self) -> Callable[ @@ -7276,7 +8119,7 @@ def get_google_channel_config(self) -> Callable[ google_channel_config.GoogleChannelConfig]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetGoogleChannelConfig(self._session, self._host, self._interceptor) # type: ignore + return self._GetGoogleChannelConfig(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_message_bus(self) -> Callable[ @@ -7284,7 +8127,7 @@ def get_message_bus(self) -> Callable[ message_bus.MessageBus]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetMessageBus(self._session, self._host, self._interceptor) # type: ignore + return self._GetMessageBus(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_pipeline(self) -> Callable[ @@ -7292,7 +8135,7 @@ def get_pipeline(self) -> Callable[ pipeline.Pipeline]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetPipeline(self._session, self._host, self._interceptor) # type: ignore + return self._GetPipeline(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_provider(self) -> Callable[ @@ -7300,7 +8143,7 @@ def get_provider(self) -> Callable[ discovery.Provider]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetProvider(self._session, self._host, self._interceptor) # type: ignore + return self._GetProvider(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_trigger(self) -> Callable[ @@ -7308,7 +8151,7 @@ def get_trigger(self) -> Callable[ trigger.Trigger]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetTrigger(self._session, self._host, self._interceptor) # type: ignore + return self._GetTrigger(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_channel_connections(self) -> Callable[ @@ -7316,7 +8159,7 @@ def list_channel_connections(self) -> Callable[ eventarc.ListChannelConnectionsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListChannelConnections(self._session, self._host, self._interceptor) # type: ignore + return self._ListChannelConnections(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_channels(self) -> Callable[ @@ -7324,7 +8167,7 @@ def list_channels(self) -> Callable[ eventarc.ListChannelsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListChannels(self._session, self._host, self._interceptor) # type: ignore + return self._ListChannels(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_enrollments(self) -> Callable[ @@ -7332,7 +8175,7 @@ def list_enrollments(self) -> Callable[ eventarc.ListEnrollmentsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListEnrollments(self._session, self._host, self._interceptor) # type: ignore + return self._ListEnrollments(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_google_api_sources(self) -> Callable[ @@ -7340,7 +8183,7 @@ def list_google_api_sources(self) -> Callable[ eventarc.ListGoogleApiSourcesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListGoogleApiSources(self._session, self._host, self._interceptor) # type: ignore + return self._ListGoogleApiSources(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_message_bus_enrollments(self) -> Callable[ @@ -7348,7 +8191,7 @@ def list_message_bus_enrollments(self) -> Callable[ eventarc.ListMessageBusEnrollmentsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListMessageBusEnrollments(self._session, self._host, self._interceptor) # type: ignore + return self._ListMessageBusEnrollments(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_message_buses(self) -> Callable[ @@ -7356,7 +8199,7 @@ def list_message_buses(self) -> Callable[ eventarc.ListMessageBusesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListMessageBuses(self._session, self._host, self._interceptor) # type: ignore + return self._ListMessageBuses(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_pipelines(self) -> Callable[ @@ -7364,7 +8207,7 @@ def list_pipelines(self) -> Callable[ eventarc.ListPipelinesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListPipelines(self._session, self._host, self._interceptor) # type: ignore + return self._ListPipelines(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_providers(self) -> Callable[ @@ -7372,7 +8215,7 @@ def list_providers(self) -> Callable[ eventarc.ListProvidersResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListProviders(self._session, self._host, self._interceptor) # type: ignore + return self._ListProviders(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_triggers(self) -> Callable[ @@ -7380,7 +8223,7 @@ def list_triggers(self) -> Callable[ eventarc.ListTriggersResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListTriggers(self._session, self._host, self._interceptor) # type: ignore + return self._ListTriggers(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_channel(self) -> Callable[ @@ -7388,7 +8231,7 @@ def update_channel(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateChannel(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateChannel(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_enrollment(self) -> Callable[ @@ -7396,7 +8239,7 @@ def update_enrollment(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateEnrollment(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateEnrollment(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_google_api_source(self) -> Callable[ @@ -7404,7 +8247,7 @@ def update_google_api_source(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateGoogleApiSource(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateGoogleApiSource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_google_channel_config(self) -> Callable[ @@ -7412,7 +8255,7 @@ def update_google_channel_config(self) -> Callable[ gce_google_channel_config.GoogleChannelConfig]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateGoogleChannelConfig(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateGoogleChannelConfig(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_message_bus(self) -> Callable[ @@ -7420,7 +8263,7 @@ def update_message_bus(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateMessageBus(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateMessageBus(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_pipeline(self) -> Callable[ @@ -7428,7 +8271,7 @@ def update_pipeline(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdatePipeline(self._session, self._host, self._interceptor) # type: ignore + return self._UpdatePipeline(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_trigger(self) -> Callable[ @@ -7436,11 +8279,11 @@ def update_trigger(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateTrigger(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateTrigger(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseEventarcRestTransport._BaseGetLocation, EventarcRestStub): def __hash__(self): @@ -7454,19 +8297,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -7529,7 +8386,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -7563,7 +8428,7 @@ def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseEventarcRestTransport._BaseListLocations, EventarcRestStub): def __hash__(self): @@ -7577,19 +8442,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -7652,7 +8531,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -7686,7 +8573,7 @@ def __call__(self, @property def get_iam_policy(self): - return self._GetIamPolicy(self._session, self._host, self._interceptor) # type: ignore + return self._GetIamPolicy(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetIamPolicy(_BaseEventarcRestTransport._BaseGetIamPolicy, EventarcRestStub): def __hash__(self): @@ -7700,19 +8587,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -7775,7 +8676,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetIamPolicy._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -7809,7 +8718,7 @@ def __call__(self, @property def set_iam_policy(self): - return self._SetIamPolicy(self._session, self._host, self._interceptor) # type: ignore + return self._SetIamPolicy(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _SetIamPolicy(_BaseEventarcRestTransport._BaseSetIamPolicy, EventarcRestStub): def __hash__(self): @@ -7823,20 +8732,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -7899,7 +8822,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._SetIamPolicy._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -7933,7 +8865,7 @@ def __call__(self, @property def test_iam_permissions(self): - return self._TestIamPermissions(self._session, self._host, self._interceptor) # type: ignore + return self._TestIamPermissions(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _TestIamPermissions(_BaseEventarcRestTransport._BaseTestIamPermissions, EventarcRestStub): def __hash__(self): @@ -7947,20 +8879,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -8023,7 +8969,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._TestIamPermissions._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -8057,7 +9012,7 @@ def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseEventarcRestTransport._BaseCancelOperation, EventarcRestStub): def __hash__(self): @@ -8071,20 +9026,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -8144,7 +9113,16 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EventarcRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -8155,7 +9133,7 @@ def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseEventarcRestTransport._BaseDeleteOperation, EventarcRestStub): def __hash__(self): @@ -8169,19 +9147,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -8241,7 +9233,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -8252,7 +9252,7 @@ def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseEventarcRestTransport._BaseGetOperation, EventarcRestStub): def __hash__(self): @@ -8266,19 +9266,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -8341,7 +9355,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -8375,7 +9397,7 @@ def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseEventarcRestTransport._BaseListOperations, EventarcRestStub): def __hash__(self): @@ -8389,19 +9411,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - ) - return response + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -8464,7 +9500,15 @@ def __call__(self, ) # Send the request - response = EventarcRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EventarcRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest_base.py index 614f97dd14c5..8ee51465cd79 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from google.iam.v1 import iam_policy_pb2 # type: ignore @@ -61,6 +62,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -81,6 +84,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -96,7 +102,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseCreateChannel: diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_compat.py index c73490e1195a..d2e3b45f4bef 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.eventarc_v1._compat import transcode_request -from google.cloud.eventarc_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.eventarc_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.eventarc_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.eventarc_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) 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..b155f3c7b4b7 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,194 @@ 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 + + +def test_eventarc_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.EventarcGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.EventarcGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_eventarc_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.EventarcGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_eventarc_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.EventarcGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.EventarcGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -30740,13 +30928,7 @@ def test_eventarc_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_eventarc_base_transport_with_credentials_file(): @@ -30776,6 +30958,94 @@ 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 + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/_compat.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..798a0425a87d 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -538,18 +545,29 @@ 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) + ): + 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..cb0487b0eea9 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +70,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 +101,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 +141,137 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +286,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 +303,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 +326,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 +343,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 +384,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 +401,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 +430,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", ), } @@ -719,7 +813,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..84190b7a1296 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,20 @@ 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 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 +from google.cloud.logging_v2._compat import _observability 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 @@ -36,6 +38,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import ConfigServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -132,6 +139,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 +198,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 +260,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 +282,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/config_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py index e49afb2aa807..87d636d23379 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -179,6 +180,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -230,6 +234,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -285,6 +294,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -306,10 +317,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -1273,74 +1326,90 @@ def copy_log_entries(self) -> Callable[ return self._stubs['copy_log_entries'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_buckets: self._wrap_method( + self.list_buckets: self._wrap_async_method( self.list_buckets, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListBuckets", ), - self.get_bucket: self._wrap_method( + self.get_bucket: self._wrap_async_method( self.get_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetBucket", ), - self.create_bucket_async: self._wrap_method( + self.create_bucket_async: self._wrap_async_method( self.create_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucketAsync", ), - self.update_bucket_async: self._wrap_method( + self.update_bucket_async: self._wrap_async_method( self.update_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucketAsync", ), - self.create_bucket: self._wrap_method( + self.create_bucket: self._wrap_async_method( self.create_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucket", ), - self.update_bucket: self._wrap_method( + self.update_bucket: self._wrap_async_method( self.update_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucket", ), - self.delete_bucket: self._wrap_method( + self.delete_bucket: self._wrap_async_method( self.delete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteBucket", ), - self.undelete_bucket: self._wrap_method( + self.undelete_bucket: self._wrap_async_method( self.undelete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UndeleteBucket", ), - self.list_views: self._wrap_method( + self.list_views: self._wrap_async_method( self.list_views, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListViews", ), - self.get_view: self._wrap_method( + self.get_view: self._wrap_async_method( self.get_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetView", ), - self.create_view: self._wrap_method( + self.create_view: self._wrap_async_method( self.create_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateView", ), - self.update_view: self._wrap_method( + self.update_view: self._wrap_async_method( self.update_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateView", ), - self.delete_view: self._wrap_method( + self.delete_view: self._wrap_async_method( self.delete_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteView", ), - self.list_sinks: self._wrap_method( + self.list_sinks: self._wrap_async_method( self.list_sinks, default_retry=retries.AsyncRetry( initial=0.1, @@ -1355,8 +1424,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: self._wrap_method( + self.get_sink: self._wrap_async_method( self.get_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1371,13 +1441,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: self._wrap_method( + self.create_sink: self._wrap_async_method( self.create_sink, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateSink", ), - self.update_sink: self._wrap_method( + self.update_sink: self._wrap_async_method( self.update_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1392,8 +1464,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: self._wrap_method( + self.delete_sink: self._wrap_async_method( self.delete_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1408,28 +1481,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: self._wrap_method( + self.create_link: self._wrap_async_method( self.create_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateLink", ), - self.delete_link: self._wrap_method( + self.delete_link: self._wrap_async_method( self.delete_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteLink", ), - self.list_links: self._wrap_method( + self.list_links: self._wrap_async_method( self.list_links, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListLinks", ), - self.get_link: self._wrap_method( + self.get_link: self._wrap_async_method( self.get_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetLink", ), - self.list_exclusions: self._wrap_method( + self.list_exclusions: self._wrap_async_method( self.list_exclusions, default_retry=retries.AsyncRetry( initial=0.1, @@ -1444,8 +1522,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: self._wrap_method( + self.get_exclusion: self._wrap_async_method( self.get_exclusion, default_retry=retries.AsyncRetry( initial=0.1, @@ -1460,18 +1539,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: self._wrap_method( + self.create_exclusion: self._wrap_async_method( self.create_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateExclusion", ), - self.update_exclusion: self._wrap_method( + self.update_exclusion: self._wrap_async_method( self.update_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateExclusion", ), - self.delete_exclusion: self._wrap_method( + self.delete_exclusion: self._wrap_async_method( self.delete_exclusion, default_retry=retries.AsyncRetry( initial=0.1, @@ -1486,54 +1568,58 @@ 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: self._wrap_method( + self.get_cmek_settings: self._wrap_async_method( self.get_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetCmekSettings", ), - self.update_cmek_settings: self._wrap_method( + self.update_cmek_settings: self._wrap_async_method( self.update_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateCmekSettings", ), - self.get_settings: self._wrap_method( + self.get_settings: self._wrap_async_method( self.get_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSettings", ), - self.update_settings: self._wrap_method( + self.update_settings: self._wrap_async_method( self.update_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSettings", ), - self.copy_log_entries: self._wrap_method( + self.copy_log_entries: self._wrap_async_method( self.copy_log_entries, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CopyLogEntries", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() 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..a87c97c94299 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -469,18 +476,29 @@ 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) + ): + 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..ab6c83989756 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +70,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 +101,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 +141,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +208,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 +225,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 +242,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 +259,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 +276,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 +293,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", ), } @@ -339,7 +408,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..63429e2cb581 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,19 @@ 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 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +from google.cloud.logging_v2._compat import _observability 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 @@ -35,6 +37,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import LoggingServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -131,6 +138,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 +197,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 +258,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 +280,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/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py index 8e816f748369..435bf3381230 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -24,6 +23,8 @@ from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -178,6 +179,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -229,6 +233,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -283,6 +292,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -304,10 +315,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -497,9 +550,12 @@ def tail_log_entries(self) -> Callable[ return self._stubs['tail_log_entries'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.delete_log: self._wrap_method( + self.delete_log: self._wrap_async_method( self.delete_log, default_retry=retries.AsyncRetry( initial=0.1, @@ -514,8 +570,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: self._wrap_method( + self.write_log_entries: self._wrap_async_method( self.write_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -530,8 +587,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: self._wrap_method( + self.list_log_entries: self._wrap_async_method( self.list_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -546,8 +604,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: self._wrap_method( + self.list_monitored_resource_descriptors: self._wrap_async_method( self.list_monitored_resource_descriptors, default_retry=retries.AsyncRetry( initial=0.1, @@ -562,8 +621,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: self._wrap_method( + self.list_logs: self._wrap_async_method( self.list_logs, default_retry=retries.AsyncRetry( initial=0.1, @@ -578,8 +638,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: self._wrap_method( + self.tail_log_entries: self._wrap_async_method( self.tail_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -594,29 +655,29 @@ 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: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() 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..0cde89ca1291 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -470,18 +477,29 @@ 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) + ): + 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..ff36d8b98de0 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +70,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 +101,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 +141,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +208,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 +225,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 +248,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 +265,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", ), } @@ -303,7 +370,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..2c186b86cd44 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,19 @@ 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 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +from google.cloud.logging_v2._compat import _observability 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 @@ -35,6 +37,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import MetricsServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -131,6 +138,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 +197,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 +258,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 +280,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/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py index aaa422d2953e..cb83e3092c2e 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -24,6 +23,8 @@ from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -178,6 +179,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -229,6 +233,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -283,6 +292,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -304,10 +315,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -452,9 +505,12 @@ def delete_log_metric(self) -> Callable[ return self._stubs['delete_log_metric'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_log_metrics: self._wrap_method( + self.list_log_metrics: self._wrap_async_method( self.list_log_metrics, default_retry=retries.AsyncRetry( initial=0.1, @@ -469,8 +525,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: self._wrap_method( + self.get_log_metric: self._wrap_async_method( self.get_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -485,13 +542,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: self._wrap_method( + self.create_log_metric: self._wrap_async_method( self.create_log_metric, default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/CreateLogMetric", ), - self.update_log_metric: self._wrap_method( + self.update_log_metric: self._wrap_async_method( self.update_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -506,8 +565,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: self._wrap_method( + self.delete_log_metric: self._wrap_async_method( self.delete_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -522,29 +582,28 @@ 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: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_compat.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_compat.py index bce857df2a1d..fd43efb178b6 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.logging_v2._compat import transcode_request -from google.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.logging_v2 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.logging_v2 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) 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..6f182792654a 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,194 @@ 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 + + +def test_config_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -12654,13 +12842,7 @@ def test_config_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_config_service_v2_base_transport_with_credentials_file(): @@ -12693,6 +12875,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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..5a0c4414fb7d 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,194 @@ 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 + + +def test_logging_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -3245,13 +3433,7 @@ def test_logging_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.close() - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_logging_service_v2_base_transport_with_credentials_file(): @@ -3285,6 +3467,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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..34109a10343c 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,194 @@ 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 + + +def test_metrics_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -3045,13 +3233,7 @@ def test_metrics_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.close() - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_metrics_service_v2_base_transport_with_credentials_file(): @@ -3085,6 +3267,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/_compat.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..649a3c1ad9a1 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -538,18 +545,29 @@ 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) + ): + 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..cb0487b0eea9 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +70,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 +101,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 +141,137 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +286,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 +303,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 +326,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 +343,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 +384,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 +401,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 +430,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", ), } @@ -719,7 +813,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..84190b7a1296 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,20 @@ 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 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 +from google.cloud.logging_v2._compat import _observability 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 @@ -36,6 +38,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import ConfigServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -132,6 +139,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 +198,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 +260,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 +282,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/config_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py index e49afb2aa807..87d636d23379 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -179,6 +180,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -230,6 +234,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -285,6 +294,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -306,10 +317,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -1273,74 +1326,90 @@ def copy_log_entries(self) -> Callable[ return self._stubs['copy_log_entries'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_buckets: self._wrap_method( + self.list_buckets: self._wrap_async_method( self.list_buckets, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListBuckets", ), - self.get_bucket: self._wrap_method( + self.get_bucket: self._wrap_async_method( self.get_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetBucket", ), - self.create_bucket_async: self._wrap_method( + self.create_bucket_async: self._wrap_async_method( self.create_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucketAsync", ), - self.update_bucket_async: self._wrap_method( + self.update_bucket_async: self._wrap_async_method( self.update_bucket_async, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucketAsync", ), - self.create_bucket: self._wrap_method( + self.create_bucket: self._wrap_async_method( self.create_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateBucket", ), - self.update_bucket: self._wrap_method( + self.update_bucket: self._wrap_async_method( self.update_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateBucket", ), - self.delete_bucket: self._wrap_method( + self.delete_bucket: self._wrap_async_method( self.delete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteBucket", ), - self.undelete_bucket: self._wrap_method( + self.undelete_bucket: self._wrap_async_method( self.undelete_bucket, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UndeleteBucket", ), - self.list_views: self._wrap_method( + self.list_views: self._wrap_async_method( self.list_views, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListViews", ), - self.get_view: self._wrap_method( + self.get_view: self._wrap_async_method( self.get_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetView", ), - self.create_view: self._wrap_method( + self.create_view: self._wrap_async_method( self.create_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateView", ), - self.update_view: self._wrap_method( + self.update_view: self._wrap_async_method( self.update_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateView", ), - self.delete_view: self._wrap_method( + self.delete_view: self._wrap_async_method( self.delete_view, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteView", ), - self.list_sinks: self._wrap_method( + self.list_sinks: self._wrap_async_method( self.list_sinks, default_retry=retries.AsyncRetry( initial=0.1, @@ -1355,8 +1424,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: self._wrap_method( + self.get_sink: self._wrap_async_method( self.get_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1371,13 +1441,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: self._wrap_method( + self.create_sink: self._wrap_async_method( self.create_sink, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateSink", ), - self.update_sink: self._wrap_method( + self.update_sink: self._wrap_async_method( self.update_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1392,8 +1464,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: self._wrap_method( + self.delete_sink: self._wrap_async_method( self.delete_sink, default_retry=retries.AsyncRetry( initial=0.1, @@ -1408,28 +1481,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: self._wrap_method( + self.create_link: self._wrap_async_method( self.create_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateLink", ), - self.delete_link: self._wrap_method( + self.delete_link: self._wrap_async_method( self.delete_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/DeleteLink", ), - self.list_links: self._wrap_method( + self.list_links: self._wrap_async_method( self.list_links, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/ListLinks", ), - self.get_link: self._wrap_method( + self.get_link: self._wrap_async_method( self.get_link, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetLink", ), - self.list_exclusions: self._wrap_method( + self.list_exclusions: self._wrap_async_method( self.list_exclusions, default_retry=retries.AsyncRetry( initial=0.1, @@ -1444,8 +1522,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: self._wrap_method( + self.get_exclusion: self._wrap_async_method( self.get_exclusion, default_retry=retries.AsyncRetry( initial=0.1, @@ -1460,18 +1539,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: self._wrap_method( + self.create_exclusion: self._wrap_async_method( self.create_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CreateExclusion", ), - self.update_exclusion: self._wrap_method( + self.update_exclusion: self._wrap_async_method( self.update_exclusion, default_timeout=120.0, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateExclusion", ), - self.delete_exclusion: self._wrap_method( + self.delete_exclusion: self._wrap_async_method( self.delete_exclusion, default_retry=retries.AsyncRetry( initial=0.1, @@ -1486,54 +1568,58 @@ 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: self._wrap_method( + self.get_cmek_settings: self._wrap_async_method( self.get_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetCmekSettings", ), - self.update_cmek_settings: self._wrap_method( + self.update_cmek_settings: self._wrap_async_method( self.update_cmek_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateCmekSettings", ), - self.get_settings: self._wrap_method( + self.get_settings: self._wrap_async_method( self.get_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/GetSettings", ), - self.update_settings: self._wrap_method( + self.update_settings: self._wrap_async_method( self.update_settings, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/UpdateSettings", ), - self.copy_log_entries: self._wrap_method( + self.copy_log_entries: self._wrap_async_method( self.copy_log_entries, default_timeout=None, client_info=client_info, + method_name="google.logging.v2.ConfigServiceV2/CopyLogEntries", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() 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..a87c97c94299 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -469,18 +476,29 @@ 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) + ): + 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..ab6c83989756 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +70,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 +101,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 +141,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +208,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 +225,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 +242,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 +259,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 +276,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 +293,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", ), } @@ -339,7 +408,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..63429e2cb581 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,19 @@ 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 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +from google.cloud.logging_v2._compat import _observability 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 @@ -35,6 +37,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import LoggingServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -131,6 +138,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 +197,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 +258,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 +280,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/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py index 8e816f748369..435bf3381230 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -24,6 +23,8 @@ from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -178,6 +179,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -229,6 +233,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -283,6 +292,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -304,10 +315,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -497,9 +550,12 @@ def tail_log_entries(self) -> Callable[ return self._stubs['tail_log_entries'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.delete_log: self._wrap_method( + self.delete_log: self._wrap_async_method( self.delete_log, default_retry=retries.AsyncRetry( initial=0.1, @@ -514,8 +570,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: self._wrap_method( + self.write_log_entries: self._wrap_async_method( self.write_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -530,8 +587,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: self._wrap_method( + self.list_log_entries: self._wrap_async_method( self.list_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -546,8 +604,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: self._wrap_method( + self.list_monitored_resource_descriptors: self._wrap_async_method( self.list_monitored_resource_descriptors, default_retry=retries.AsyncRetry( initial=0.1, @@ -562,8 +621,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: self._wrap_method( + self.list_logs: self._wrap_async_method( self.list_logs, default_retry=retries.AsyncRetry( initial=0.1, @@ -578,8 +638,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: self._wrap_method( + self.tail_log_entries: self._wrap_async_method( self.tail_log_entries, default_retry=retries.AsyncRetry( initial=0.1, @@ -594,29 +655,29 @@ 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: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() 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..6f2263b19be2 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 @@ -27,7 +27,14 @@ 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.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -470,18 +477,29 @@ 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) + ): + 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..ff36d8b98de0 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +70,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 +101,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 +141,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +208,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 +225,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 +248,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 +265,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", ), } @@ -303,7 +370,7 @@ def cancel_operation( @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..2c186b86cd44 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,19 @@ 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 +from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 +from google.cloud.logging_v2._compat import _observability 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 @@ -35,6 +37,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import MetricsServiceV2Transport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -131,6 +138,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 +197,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 +258,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 +280,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/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py index aaa422d2953e..cb83e3092c2e 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -24,6 +23,8 @@ from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import client_options as client_options_lib +from google.cloud.logging_v2._compat import _observability 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 @@ -178,6 +179,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -229,6 +233,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -283,6 +292,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -304,10 +315,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -452,9 +505,12 @@ def delete_log_metric(self) -> Callable[ return self._stubs['delete_log_metric'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_log_metrics: self._wrap_method( + self.list_log_metrics: self._wrap_async_method( self.list_log_metrics, default_retry=retries.AsyncRetry( initial=0.1, @@ -469,8 +525,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: self._wrap_method( + self.get_log_metric: self._wrap_async_method( self.get_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -485,13 +542,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: self._wrap_method( + self.create_log_metric: self._wrap_async_method( self.create_log_metric, default_timeout=60.0, client_info=client_info, + method_name="google.logging.v2.MetricsServiceV2/CreateLogMetric", ), - self.update_log_metric: self._wrap_method( + self.update_log_metric: self._wrap_async_method( self.update_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -506,8 +565,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: self._wrap_method( + self.delete_log_metric: self._wrap_async_method( self.delete_log_metric, default_retry=retries.AsyncRetry( initial=0.1, @@ -522,29 +582,28 @@ 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: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_compat.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_compat.py index bce857df2a1d..fd43efb178b6 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.logging_v2._compat import transcode_request -from google.cloud.logging_v2._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.logging_v2._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.logging_v2 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.logging_v2 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) 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..6d5c82308996 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,194 @@ 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 + + +def test_config_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.ConfigServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.ConfigServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -12654,13 +12842,7 @@ def test_config_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_config_service_v2_base_transport_with_credentials_file(): @@ -12693,6 +12875,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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..5a0c4414fb7d 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,194 @@ 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 + + +def test_logging_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.LoggingServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.LoggingServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -3245,13 +3433,7 @@ def test_logging_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.close() - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_logging_service_v2_base_transport_with_credentials_file(): @@ -3285,6 +3467,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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..a357936bedee 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,194 @@ 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 + + +def test_metrics_service_v2_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.MetricsServiceV2GrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.MetricsServiceV2GrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -3045,13 +3233,7 @@ def test_metrics_service_v2_base_transport(): with pytest.raises(NotImplementedError): transport.close() - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_metrics_service_v2_base_transport_with_credentials_file(): @@ -3085,6 +3267,94 @@ 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 + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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/_compat.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..24b3a1cc61d6 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 @@ -27,7 +27,14 @@ 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.cloud.redis_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.redis_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -520,10 +527,14 @@ def __init__(self, *, raise core_exceptions.AsyncRestUnsupportedParameterError( # type: ignore f"The following provided parameters are not supported for `transport=rest_asyncio`: {', '.join(provided_unsupported_params)}" ) + client_options = None + if _observability is not None and _observability.is_otel_capabilities_enabled(self._client_options): # pragma: NO COVER + client_options = self._client_options # pragma: NO COVER self._transport = transport_init( credentials=credentials, host=self._api_endpoint, client_info=client_info, + **({"client_options": client_options} if client_options is not None else {}), ) return @@ -532,18 +543,29 @@ 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) + ): + 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..7ae20e727608 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,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. @@ -85,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. @@ -122,104 +138,165 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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", ), } @@ -399,7 +476,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..633dc46f5d1b 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,20 @@ 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 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 +from google.cloud.redis_v1._compat import _observability 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 @@ -36,6 +38,11 @@ from google.longrunning import operations_pb2 # type: ignore from .base import CloudRedisTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -152,6 +159,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 +218,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 +280,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 +302,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/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py index 110d71537636..37f48de5c82c 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.redis_v1._compat import _observability 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 @@ -199,6 +200,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -250,6 +254,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -305,6 +314,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -326,10 +337,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -694,105 +747,121 @@ def reschedule_maintenance(self) -> Callable[ return self._stubs['reschedule_maintenance'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_instances: self._wrap_method( + self.list_instances: self._wrap_async_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: self._wrap_method( + self.get_instance: self._wrap_async_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: self._wrap_method( + self.get_instance_auth_string: self._wrap_async_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: self._wrap_method( + self.create_instance: self._wrap_async_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: self._wrap_method( + self.update_instance: self._wrap_async_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.upgrade_instance: self._wrap_method( + self.upgrade_instance: self._wrap_async_method( self.upgrade_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpgradeInstance", ), - self.import_instance: self._wrap_method( + self.import_instance: self._wrap_async_method( self.import_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ImportInstance", ), - self.export_instance: self._wrap_method( + self.export_instance: self._wrap_async_method( self.export_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ExportInstance", ), - self.failover_instance: self._wrap_method( + self.failover_instance: self._wrap_async_method( self.failover_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/FailoverInstance", ), - self.delete_instance: self._wrap_method( + self.delete_instance: self._wrap_async_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.reschedule_maintenance: self._wrap_method( + self.reschedule_maintenance: self._wrap_async_method( self.reschedule_maintenance, default_timeout=None, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/RescheduleMaintenance", ), - self.get_location: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: self._wrap_method( + self.wait_operation: self._wrap_async_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest.py index ea8778e47a84..c8715b256a1f 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.cloud.redis_v1._compat import transcode_request +from google.cloud.redis_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -40,6 +41,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseCloudRedisRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -722,6 +724,7 @@ class CloudRedisRestStub: _session: AuthorizedSession _host: str _interceptor: CloudRedisRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class CloudRedisRestTransport(_BaseCloudRedisRestTransport): @@ -769,6 +772,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[CloudRedisRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -812,6 +817,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -823,7 +831,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -901,20 +911,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.CreateInstanceRequest, *, @@ -981,7 +1005,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._CreateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._CreateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -991,7 +1024,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_instance_with_metadata(resp, response_metadata) @@ -1028,19 +1060,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.DeleteInstanceRequest, *, @@ -1107,7 +1153,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._DeleteInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._DeleteInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1117,7 +1171,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_instance_with_metadata(resp, response_metadata) @@ -1154,20 +1207,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.ExportInstanceRequest, *, @@ -1234,7 +1301,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ExportInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._ExportInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1244,7 +1320,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_export_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_export_instance_with_metadata(resp, response_metadata) @@ -1281,20 +1356,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.FailoverInstanceRequest, *, @@ -1361,7 +1450,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._FailoverInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._FailoverInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1371,7 +1469,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_failover_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_failover_instance_with_metadata(resp, response_metadata) @@ -1408,19 +1505,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.GetInstanceRequest, *, @@ -1484,7 +1595,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1496,7 +1615,6 @@ def __call__(self, pb_resp = cloud_redis.Instance.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_instance_with_metadata(resp, response_metadata) @@ -1533,19 +1651,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.GetInstanceAuthStringRequest, *, @@ -1609,7 +1741,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetInstanceAuthString._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetInstanceAuthString._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1621,7 +1761,6 @@ def __call__(self, pb_resp = cloud_redis.InstanceAuthString.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_instance_auth_string(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_instance_auth_string_with_metadata(resp, response_metadata) @@ -1658,20 +1797,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.ImportInstanceRequest, *, @@ -1738,7 +1891,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ImportInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._ImportInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1748,7 +1910,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_import_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_import_instance_with_metadata(resp, response_metadata) @@ -1785,19 +1946,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.ListInstancesRequest, *, @@ -1863,7 +2038,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListInstances._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListInstances._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1875,7 +2058,6 @@ def __call__(self, pb_resp = cloud_redis.ListInstancesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_instances(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_instances_with_metadata(resp, response_metadata) @@ -1912,20 +2094,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.RescheduleMaintenanceRequest, *, @@ -1992,7 +2188,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._RescheduleMaintenance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._RescheduleMaintenance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2002,7 +2207,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_reschedule_maintenance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_reschedule_maintenance_with_metadata(resp, response_metadata) @@ -2039,20 +2243,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.UpdateInstanceRequest, *, @@ -2119,7 +2337,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._UpdateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._UpdateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2129,7 +2356,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_instance_with_metadata(resp, response_metadata) @@ -2166,20 +2392,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.UpgradeInstanceRequest, *, @@ -2246,7 +2486,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._UpgradeInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._UpgradeInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2256,7 +2505,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_upgrade_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_upgrade_instance_with_metadata(resp, response_metadata) @@ -2287,7 +2535,7 @@ def create_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._CreateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_instance(self) -> Callable[ @@ -2295,7 +2543,7 @@ def delete_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteInstance(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def export_instance(self) -> Callable[ @@ -2303,7 +2551,7 @@ def export_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ExportInstance(self._session, self._host, self._interceptor) # type: ignore + return self._ExportInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def failover_instance(self) -> Callable[ @@ -2311,7 +2559,7 @@ def failover_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._FailoverInstance(self._session, self._host, self._interceptor) # type: ignore + return self._FailoverInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance(self) -> Callable[ @@ -2319,7 +2567,7 @@ def get_instance(self) -> Callable[ cloud_redis.Instance]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetInstance(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance_auth_string(self) -> Callable[ @@ -2327,7 +2575,7 @@ def get_instance_auth_string(self) -> Callable[ cloud_redis.InstanceAuthString]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetInstanceAuthString(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstanceAuthString(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def import_instance(self) -> Callable[ @@ -2335,7 +2583,7 @@ def import_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ImportInstance(self._session, self._host, self._interceptor) # type: ignore + return self._ImportInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_instances(self) -> Callable[ @@ -2343,7 +2591,7 @@ def list_instances(self) -> Callable[ cloud_redis.ListInstancesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListInstances(self._session, self._host, self._interceptor) # type: ignore + return self._ListInstances(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def reschedule_maintenance(self) -> Callable[ @@ -2351,7 +2599,7 @@ def reschedule_maintenance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._RescheduleMaintenance(self._session, self._host, self._interceptor) # type: ignore + return self._RescheduleMaintenance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_instance(self) -> Callable[ @@ -2359,7 +2607,7 @@ def update_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def upgrade_instance(self) -> Callable[ @@ -2367,11 +2615,11 @@ def upgrade_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpgradeInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpgradeInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseCloudRedisRestTransport._BaseGetLocation, CloudRedisRestStub): def __hash__(self): @@ -2385,19 +2633,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -2460,7 +2722,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2494,7 +2764,7 @@ def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseCloudRedisRestTransport._BaseListLocations, CloudRedisRestStub): def __hash__(self): @@ -2508,19 +2778,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -2583,7 +2867,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2617,7 +2909,7 @@ def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseCloudRedisRestTransport._BaseCancelOperation, CloudRedisRestStub): def __hash__(self): @@ -2631,19 +2923,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -2703,7 +3009,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2714,7 +3028,7 @@ def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseCloudRedisRestTransport._BaseDeleteOperation, CloudRedisRestStub): def __hash__(self): @@ -2728,19 +3042,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -2800,7 +3128,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2811,7 +3147,7 @@ def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseCloudRedisRestTransport._BaseGetOperation, CloudRedisRestStub): def __hash__(self): @@ -2825,19 +3161,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2900,7 +3250,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2934,7 +3292,7 @@ def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseCloudRedisRestTransport._BaseListOperations, CloudRedisRestStub): def __hash__(self): @@ -2948,19 +3306,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -3023,7 +3395,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3057,7 +3437,7 @@ def __call__(self, @property def wait_operation(self): - return self._WaitOperation(self._session, self._host, self._interceptor) # type: ignore + return self._WaitOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _WaitOperation(_BaseCloudRedisRestTransport._BaseWaitOperation, CloudRedisRestStub): def __hash__(self): @@ -3071,20 +3451,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.WaitOperationRequest, *, @@ -3147,7 +3541,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._WaitOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._WaitOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py index 4d629a5a8443..0c937c420659 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py @@ -32,7 +32,7 @@ from google.api_core import retry_async as retries from google.api_core import rest_helpers from google.api_core import rest_streaming_async # type: ignore -from google.cloud.redis_v1._compat import transcode_request +from google.cloud.redis_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf @@ -40,6 +40,7 @@ from google.api_core import operations_v1 from google.cloud.location import locations_pb2 # type: ignore +import contextlib import json # type: ignore import dataclasses from typing import Any, Dict, List, Callable, Tuple, Optional, Sequence, Union @@ -49,11 +50,13 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseCloudRedisRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO +import asyncio import logging try: @@ -735,6 +738,7 @@ class AsyncCloudRedisRestStub: _session: AsyncAuthorizedSession _host: str _interceptor: AsyncCloudRedisRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class AsyncCloudRedisRestTransport(_BaseCloudRedisRestTransport): """Asynchronous REST backend transport for CloudRedis. @@ -774,6 +778,8 @@ def __init__(self, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[AsyncCloudRedisRestInterceptor] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -799,6 +805,9 @@ def __init__(self, "http" can be specified. interceptor (Optional[AsyncCloudRedisRestInterceptor]): Interceptor used to manipulate requests, request metadata, and responses. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor super().__init__( @@ -807,114 +816,131 @@ def __init__(self, client_info=client_info, always_use_jwt_access=False, url_scheme=url_scheme, - api_audience=None + api_audience=None, + client_options=client_options, + **kwargs, ) self._session = AsyncAuthorizedSession(self._credentials) # type: ignore self._interceptor = interceptor or AsyncCloudRedisRestInterceptor() - self._wrap_with_kind = True self._prep_wrapped_messages(client_info) self._operations_client: Optional[operations_v1.AsyncOperationsRestClient] = None def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_instances: self._wrap_method( + self.list_instances: self._wrap_async_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: self._wrap_method( + self.get_instance: self._wrap_async_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: self._wrap_method( + self.get_instance_auth_string: self._wrap_async_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: self._wrap_method( + self.create_instance: self._wrap_async_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: self._wrap_method( + self.update_instance: self._wrap_async_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.upgrade_instance: self._wrap_method( + self.upgrade_instance: self._wrap_async_method( self.upgrade_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpgradeInstance", ), - self.import_instance: self._wrap_method( + self.import_instance: self._wrap_async_method( self.import_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ImportInstance", ), - self.export_instance: self._wrap_method( + self.export_instance: self._wrap_async_method( self.export_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ExportInstance", ), - self.failover_instance: self._wrap_method( + self.failover_instance: self._wrap_async_method( self.failover_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/FailoverInstance", ), - self.delete_instance: self._wrap_method( + self.delete_instance: self._wrap_async_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.reschedule_maintenance: self._wrap_method( + self.reschedule_maintenance: self._wrap_async_method( self.reschedule_maintenance, default_timeout=None, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/RescheduleMaintenance", ), - self.get_location: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: self._wrap_method( + self.wait_operation: self._wrap_async_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - class _CreateInstance(_BaseCloudRedisRestTransport._BaseCreateInstance, AsyncCloudRedisRestStub): def __hash__(self): return hash("AsyncCloudRedisRestTransport.CreateInstance") @@ -927,20 +953,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.CreateInstanceRequest, *, @@ -1007,7 +1047,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._CreateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._CreateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1060,19 +1109,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.DeleteInstanceRequest, *, @@ -1139,7 +1202,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._DeleteInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._DeleteInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1192,20 +1263,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.ExportInstanceRequest, *, @@ -1272,7 +1357,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ExportInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._ExportInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1325,20 +1419,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.FailoverInstanceRequest, *, @@ -1405,7 +1513,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._FailoverInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._FailoverInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1458,19 +1575,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.GetInstanceRequest, *, @@ -1534,7 +1665,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1587,19 +1726,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.GetInstanceAuthStringRequest, *, @@ -1663,7 +1816,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetInstanceAuthString._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetInstanceAuthString._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1716,20 +1877,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.ImportInstanceRequest, *, @@ -1796,7 +1971,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ImportInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._ImportInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1849,19 +2033,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.ListInstancesRequest, *, @@ -1927,7 +2125,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListInstances._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListInstances._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1980,20 +2186,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.RescheduleMaintenanceRequest, *, @@ -2060,7 +2280,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._RescheduleMaintenance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._RescheduleMaintenance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2113,20 +2342,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.UpdateInstanceRequest, *, @@ -2193,7 +2436,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._UpdateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._UpdateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2246,20 +2498,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.UpgradeInstanceRequest, *, @@ -2326,7 +2592,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._UpgradeInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._UpgradeInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2427,71 +2702,71 @@ def operations_client(self) -> AsyncOperationsRestClient: def create_instance(self) -> Callable[ [cloud_redis.CreateInstanceRequest], operations_pb2.Operation]: - return self._CreateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._CreateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_instance(self) -> Callable[ [cloud_redis.DeleteInstanceRequest], operations_pb2.Operation]: - return self._DeleteInstance(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def export_instance(self) -> Callable[ [cloud_redis.ExportInstanceRequest], operations_pb2.Operation]: - return self._ExportInstance(self._session, self._host, self._interceptor) # type: ignore + return self._ExportInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def failover_instance(self) -> Callable[ [cloud_redis.FailoverInstanceRequest], operations_pb2.Operation]: - return self._FailoverInstance(self._session, self._host, self._interceptor) # type: ignore + return self._FailoverInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance(self) -> Callable[ [cloud_redis.GetInstanceRequest], cloud_redis.Instance]: - return self._GetInstance(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance_auth_string(self) -> Callable[ [cloud_redis.GetInstanceAuthStringRequest], cloud_redis.InstanceAuthString]: - return self._GetInstanceAuthString(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstanceAuthString(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def import_instance(self) -> Callable[ [cloud_redis.ImportInstanceRequest], operations_pb2.Operation]: - return self._ImportInstance(self._session, self._host, self._interceptor) # type: ignore + return self._ImportInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_instances(self) -> Callable[ [cloud_redis.ListInstancesRequest], cloud_redis.ListInstancesResponse]: - return self._ListInstances(self._session, self._host, self._interceptor) # type: ignore + return self._ListInstances(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def reschedule_maintenance(self) -> Callable[ [cloud_redis.RescheduleMaintenanceRequest], operations_pb2.Operation]: - return self._RescheduleMaintenance(self._session, self._host, self._interceptor) # type: ignore + return self._RescheduleMaintenance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_instance(self) -> Callable[ [cloud_redis.UpdateInstanceRequest], operations_pb2.Operation]: - return self._UpdateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def upgrade_instance(self) -> Callable[ [cloud_redis.UpgradeInstanceRequest], operations_pb2.Operation]: - return self._UpgradeInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpgradeInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseCloudRedisRestTransport._BaseGetLocation, AsyncCloudRedisRestStub): def __hash__(self): @@ -2505,19 +2780,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -2580,7 +2869,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2618,7 +2915,7 @@ async def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseCloudRedisRestTransport._BaseListLocations, AsyncCloudRedisRestStub): def __hash__(self): @@ -2632,19 +2929,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -2707,7 +3018,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2745,7 +3064,7 @@ async def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseCloudRedisRestTransport._BaseCancelOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -2759,19 +3078,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -2831,7 +3164,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2846,7 +3187,7 @@ async def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseCloudRedisRestTransport._BaseDeleteOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -2860,19 +3201,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -2932,7 +3287,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2947,7 +3310,7 @@ async def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseCloudRedisRestTransport._BaseGetOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -2961,19 +3324,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -3036,7 +3413,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3074,7 +3459,7 @@ async def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseCloudRedisRestTransport._BaseListOperations, AsyncCloudRedisRestStub): def __hash__(self): @@ -3088,19 +3473,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -3163,7 +3562,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3201,7 +3608,7 @@ async def __call__(self, @property def wait_operation(self): - return self._WaitOperation(self._session, self._host, self._interceptor) # type: ignore + return self._WaitOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _WaitOperation(_BaseCloudRedisRestTransport._BaseWaitOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -3215,20 +3622,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.WaitOperationRequest, *, @@ -3291,7 +3712,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._WaitOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._WaitOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -3333,3 +3763,9 @@ def kind(self) -> str: async def close(self): await self._session.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py index 972b14a1295e..eca2baf79f05 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from google.cloud.location import locations_pb2 # type: ignore @@ -49,6 +50,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -69,6 +72,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -84,7 +90,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseCreateInstance: 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..8f4be2d70d8d 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,194 @@ 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 + + +def test_cloud_redis_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.CloudRedisGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.CloudRedisGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.CloudRedisGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -11255,7 +11443,13 @@ def test_unsupported_parameter_rest_asyncio(): credentials=async_anonymous_credentials(), transport="rest_asyncio", client_options=options - ) + ) + with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError, match="google.api_core.client_options.ClientOptions.quota_project_id") as exc: # type: ignore + client = CloudRedisClient( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest_asyncio", + client_options=options + ) def test_transport_grpc_default(): @@ -11319,13 +11513,7 @@ def test_cloud_redis_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_cloud_redis_base_transport_with_credentials_file(): @@ -11355,6 +11543,94 @@ 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 + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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: @@ -13070,6 +13346,11 @@ async def test_transport_close_rest_asyncio(): close.assert_not_called() close.assert_called_once() + with mock.patch.object(type(getattr(client.transport, "_session")), "close") as close: + async with client.transport: + close.assert_not_called() + close.assert_called_once() + def test_client_ctx(): transports = [ diff --git a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_compat.py index f7fe8e355981..9abfdbf59c1a 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.redis_v1._compat import transcode_request -from google.cloud.redis_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.redis_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.redis_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.redis_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py index a6d3f9fbb31f..3e7480d3887f 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json @@ -26,6 +27,30 @@ from google.protobuf import json_format from urllib.parse import urlparse, urlunparse + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..0d60c5042551 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 @@ -27,7 +27,14 @@ 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.cloud.redis_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.redis_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -520,10 +527,14 @@ def __init__(self, *, raise core_exceptions.AsyncRestUnsupportedParameterError( # type: ignore f"The following provided parameters are not supported for `transport=rest_asyncio`: {', '.join(provided_unsupported_params)}" ) + client_options = None + if _observability is not None and _observability.is_otel_capabilities_enabled(self._client_options): # pragma: NO COVER + client_options = self._client_options # pragma: NO COVER self._transport = transport_init( credentials=credentials, host=self._api_endpoint, client_info=client_info, + **({"client_options": client_options} if client_options is not None else {}), ) return @@ -532,18 +543,29 @@ 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) + ): + 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..cf9b230910b3 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,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. @@ -85,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. @@ -122,74 +138,129 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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", ), } @@ -315,7 +386,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..a6553955a300 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,20 @@ 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 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 +from google.cloud.redis_v1._compat import _observability 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 @@ -36,6 +38,11 @@ from google.longrunning import operations_pb2 # type: ignore from .base import CloudRedisTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -152,6 +159,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 +218,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 +280,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 +302,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/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py index c7b03489475f..e14b6633124d 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.redis_v1._compat import _observability 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 @@ -199,6 +200,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -250,6 +254,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -305,6 +314,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -326,10 +337,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -518,75 +571,85 @@ def delete_instance(self) -> Callable[ return self._stubs['delete_instance'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_instances: self._wrap_method( + self.list_instances: self._wrap_async_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: self._wrap_method( + self.get_instance: self._wrap_async_method( self.get_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/GetInstance", ), - self.create_instance: self._wrap_method( + self.create_instance: self._wrap_async_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: self._wrap_method( + self.update_instance: self._wrap_async_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.delete_instance: self._wrap_method( + self.delete_instance: self._wrap_async_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.get_location: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: self._wrap_method( + self.wait_operation: self._wrap_async_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest.py index 2f972ef00317..03d4ee554380 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.cloud.redis_v1._compat import transcode_request +from google.cloud.redis_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -40,6 +41,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseCloudRedisRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -458,6 +460,7 @@ class CloudRedisRestStub: _session: AuthorizedSession _host: str _interceptor: CloudRedisRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class CloudRedisRestTransport(_BaseCloudRedisRestTransport): @@ -505,6 +508,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[CloudRedisRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -548,6 +553,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -559,7 +567,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -637,20 +647,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.CreateInstanceRequest, *, @@ -717,7 +741,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._CreateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._CreateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -727,7 +760,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_instance_with_metadata(resp, response_metadata) @@ -764,19 +796,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.DeleteInstanceRequest, *, @@ -843,7 +889,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._DeleteInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._DeleteInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -853,7 +907,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_delete_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_delete_instance_with_metadata(resp, response_metadata) @@ -890,19 +943,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.GetInstanceRequest, *, @@ -966,7 +1033,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -978,7 +1053,6 @@ def __call__(self, pb_resp = cloud_redis.Instance.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_instance_with_metadata(resp, response_metadata) @@ -1015,19 +1089,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.ListInstancesRequest, *, @@ -1093,7 +1181,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListInstances._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListInstances._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1105,7 +1201,6 @@ def __call__(self, pb_resp = cloud_redis.ListInstancesResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_instances(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_instances_with_metadata(resp, response_metadata) @@ -1142,20 +1237,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: cloud_redis.UpdateInstanceRequest, *, @@ -1222,7 +1331,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._UpdateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._UpdateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1232,7 +1350,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_instance(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_instance_with_metadata(resp, response_metadata) @@ -1263,7 +1380,7 @@ def create_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._CreateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_instance(self) -> Callable[ @@ -1271,7 +1388,7 @@ def delete_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteInstance(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance(self) -> Callable[ @@ -1279,7 +1396,7 @@ def get_instance(self) -> Callable[ cloud_redis.Instance]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetInstance(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_instances(self) -> Callable[ @@ -1287,7 +1404,7 @@ def list_instances(self) -> Callable[ cloud_redis.ListInstancesResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListInstances(self._session, self._host, self._interceptor) # type: ignore + return self._ListInstances(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_instance(self) -> Callable[ @@ -1295,11 +1412,11 @@ def update_instance(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._UpdateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseCloudRedisRestTransport._BaseGetLocation, CloudRedisRestStub): def __hash__(self): @@ -1313,19 +1430,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1388,7 +1519,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1422,7 +1561,7 @@ def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseCloudRedisRestTransport._BaseListLocations, CloudRedisRestStub): def __hash__(self): @@ -1436,19 +1575,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1511,7 +1664,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1545,7 +1706,7 @@ def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseCloudRedisRestTransport._BaseCancelOperation, CloudRedisRestStub): def __hash__(self): @@ -1559,19 +1720,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -1631,7 +1806,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1642,7 +1825,7 @@ def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseCloudRedisRestTransport._BaseDeleteOperation, CloudRedisRestStub): def __hash__(self): @@ -1656,19 +1839,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -1728,7 +1925,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1739,7 +1944,7 @@ def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseCloudRedisRestTransport._BaseGetOperation, CloudRedisRestStub): def __hash__(self): @@ -1753,19 +1958,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -1828,7 +2047,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1862,7 +2089,7 @@ def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseCloudRedisRestTransport._BaseListOperations, CloudRedisRestStub): def __hash__(self): @@ -1876,19 +2103,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -1951,7 +2192,15 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = CloudRedisRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1985,7 +2234,7 @@ def __call__(self, @property def wait_operation(self): - return self._WaitOperation(self._session, self._host, self._interceptor) # type: ignore + return self._WaitOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _WaitOperation(_BaseCloudRedisRestTransport._BaseWaitOperation, CloudRedisRestStub): def __hash__(self): @@ -1999,20 +2248,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.WaitOperationRequest, *, @@ -2075,7 +2338,16 @@ def __call__(self, ) # Send the request - response = CloudRedisRestTransport._WaitOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = CloudRedisRestTransport._WaitOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py index 960d9639a214..53b84ed32d12 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py @@ -32,7 +32,7 @@ from google.api_core import retry_async as retries from google.api_core import rest_helpers from google.api_core import rest_streaming_async # type: ignore -from google.cloud.redis_v1._compat import transcode_request +from google.cloud.redis_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf @@ -40,6 +40,7 @@ from google.api_core import operations_v1 from google.cloud.location import locations_pb2 # type: ignore +import contextlib import json # type: ignore import dataclasses from typing import Any, Dict, List, Callable, Tuple, Optional, Sequence, Union @@ -49,11 +50,13 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseCloudRedisRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO +import asyncio import logging try: @@ -471,6 +474,7 @@ class AsyncCloudRedisRestStub: _session: AsyncAuthorizedSession _host: str _interceptor: AsyncCloudRedisRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class AsyncCloudRedisRestTransport(_BaseCloudRedisRestTransport): """Asynchronous REST backend transport for CloudRedis. @@ -510,6 +514,8 @@ def __init__(self, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[AsyncCloudRedisRestInterceptor] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -535,6 +541,9 @@ def __init__(self, "http" can be specified. interceptor (Optional[AsyncCloudRedisRestInterceptor]): Interceptor used to manipulate requests, request metadata, and responses. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor super().__init__( @@ -543,84 +552,95 @@ def __init__(self, client_info=client_info, always_use_jwt_access=False, url_scheme=url_scheme, - api_audience=None + api_audience=None, + client_options=client_options, + **kwargs, ) self._session = AsyncAuthorizedSession(self._credentials) # type: ignore self._interceptor = interceptor or AsyncCloudRedisRestInterceptor() - self._wrap_with_kind = True self._prep_wrapped_messages(client_info) self._operations_client: Optional[operations_v1.AsyncOperationsRestClient] = None def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_instances: self._wrap_method( + self.list_instances: self._wrap_async_method( self.list_instances, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/ListInstances", ), - self.get_instance: self._wrap_method( + self.get_instance: self._wrap_async_method( self.get_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/GetInstance", ), - self.create_instance: self._wrap_method( + self.create_instance: self._wrap_async_method( self.create_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/CreateInstance", ), - self.update_instance: self._wrap_method( + self.update_instance: self._wrap_async_method( self.update_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/UpdateInstance", ), - self.delete_instance: self._wrap_method( + self.delete_instance: self._wrap_async_method( self.delete_instance, default_timeout=600.0, client_info=client_info, + method_name="google.cloud.redis.v1.CloudRedis/DeleteInstance", ), - self.get_location: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), - self.wait_operation: self._wrap_method( + self.wait_operation: self._wrap_async_method( self.wait_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/WaitOperation", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - class _CreateInstance(_BaseCloudRedisRestTransport._BaseCreateInstance, AsyncCloudRedisRestStub): def __hash__(self): return hash("AsyncCloudRedisRestTransport.CreateInstance") @@ -633,20 +653,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.CreateInstanceRequest, *, @@ -713,7 +747,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._CreateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._CreateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -766,19 +809,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.DeleteInstanceRequest, *, @@ -845,7 +902,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._DeleteInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._DeleteInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -898,19 +963,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.GetInstanceRequest, *, @@ -974,7 +1053,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1027,19 +1114,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.ListInstancesRequest, *, @@ -1105,7 +1206,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListInstances._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListInstances._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1158,20 +1267,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: cloud_redis.UpdateInstanceRequest, *, @@ -1238,7 +1361,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._UpdateInstance._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._UpdateInstance._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1339,35 +1471,35 @@ def operations_client(self) -> AsyncOperationsRestClient: def create_instance(self) -> Callable[ [cloud_redis.CreateInstanceRequest], operations_pb2.Operation]: - return self._CreateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._CreateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_instance(self) -> Callable[ [cloud_redis.DeleteInstanceRequest], operations_pb2.Operation]: - return self._DeleteInstance(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_instance(self) -> Callable[ [cloud_redis.GetInstanceRequest], cloud_redis.Instance]: - return self._GetInstance(self._session, self._host, self._interceptor) # type: ignore + return self._GetInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_instances(self) -> Callable[ [cloud_redis.ListInstancesRequest], cloud_redis.ListInstancesResponse]: - return self._ListInstances(self._session, self._host, self._interceptor) # type: ignore + return self._ListInstances(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_instance(self) -> Callable[ [cloud_redis.UpdateInstanceRequest], operations_pb2.Operation]: - return self._UpdateInstance(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateInstance(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseCloudRedisRestTransport._BaseGetLocation, AsyncCloudRedisRestStub): def __hash__(self): @@ -1381,19 +1513,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1456,7 +1602,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1494,7 +1648,7 @@ async def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseCloudRedisRestTransport._BaseListLocations, AsyncCloudRedisRestStub): def __hash__(self): @@ -1508,19 +1662,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1583,7 +1751,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1621,7 +1797,7 @@ async def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseCloudRedisRestTransport._BaseCancelOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -1635,19 +1811,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -1707,7 +1897,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1722,7 +1920,7 @@ async def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseCloudRedisRestTransport._BaseDeleteOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -1736,19 +1934,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -1808,7 +2020,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1823,7 +2043,7 @@ async def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseCloudRedisRestTransport._BaseGetOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -1837,19 +2057,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -1912,7 +2146,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1950,7 +2192,7 @@ async def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseCloudRedisRestTransport._BaseListOperations, AsyncCloudRedisRestStub): def __hash__(self): @@ -1964,19 +2206,33 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2039,7 +2295,15 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = await AsyncCloudRedisRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2077,7 +2341,7 @@ async def __call__(self, @property def wait_operation(self): - return self._WaitOperation(self._session, self._host, self._interceptor) # type: ignore + return self._WaitOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _WaitOperation(_BaseCloudRedisRestTransport._BaseWaitOperation, AsyncCloudRedisRestStub): def __hash__(self): @@ -2091,20 +2355,34 @@ async def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = await getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response async def __call__(self, request: operations_pb2.WaitOperationRequest, *, @@ -2167,7 +2445,16 @@ async def __call__(self, ) # Send the request - response = await AsyncCloudRedisRestTransport._WaitOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = await AsyncCloudRedisRestTransport._WaitOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2209,3 +2496,9 @@ def kind(self) -> str: async def close(self): await self._session.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py index 7e9b4428533c..d585f247733b 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from google.cloud.location import locations_pb2 # type: ignore @@ -49,6 +50,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -69,6 +72,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -84,7 +90,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseCreateInstance: 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..ffb1e02dd365 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,194 @@ 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 + + +def test_cloud_redis_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.CloudRedisGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.CloudRedisGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.CloudRedisGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.CloudRedisGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -6499,7 +6687,13 @@ def test_unsupported_parameter_rest_asyncio(): credentials=async_anonymous_credentials(), transport="rest_asyncio", client_options=options - ) + ) + with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError, match="google.api_core.client_options.ClientOptions.quota_project_id") as exc: # type: ignore + client = CloudRedisClient( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest_asyncio", + client_options=options + ) def test_transport_grpc_default(): @@ -6557,13 +6751,7 @@ def test_cloud_redis_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_cloud_redis_base_transport_with_credentials_file(): @@ -6593,6 +6781,94 @@ 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 + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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: @@ -8290,6 +8566,11 @@ async def test_transport_close_rest_asyncio(): close.assert_not_called() close.assert_called_once() + with mock.patch.object(type(getattr(client.transport, "_session")), "close") as close: + async with client.transport: + close.assert_not_called() + close.assert_called_once() + def test_client_ctx(): transports = [ diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_compat.py index f7fe8e355981..9abfdbf59c1a 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_compat.py @@ -24,7 +24,16 @@ import google.auth.transport.mtls from google.cloud.redis_v1._compat import transcode_request -from google.cloud.redis_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.redis_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.redis_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.redis_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py index d7096741a7f9..26d1fa940035 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py @@ -15,6 +15,7 @@ # """A compatibility module for older versions of google-api-core.""" +import contextlib import os import json import uuid @@ -23,15 +24,38 @@ from typing import Any, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Union -if TYPE_CHECKING: # pragma: NO COVER - import proto # type: ignore[import-untyped] - from google.api_core import path_template from google.api_core.universe import EmptyUniverseError from google.auth.exceptions import MutualTLSChannelError from google.protobuf import json_format from urllib.parse import urlparse, urlunparse +if TYPE_CHECKING: # pragma: NO COVER + import proto # type: ignore[import-untyped] + +# 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] + +if _observability is not None and hasattr(_observability, "trace_http_request"): + trace_http_request = _observability.trace_http_request +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + @contextlib.contextmanager + def trace_http_request(*args: Any, **kwargs: Any): + yield None + +if _observability is not None and hasattr(_observability, "record_http_response"): + record_http_response = _observability.record_http_response +else: # pragma: NO COVER + # Fallback for older versions of google-api-core without HTTP tracing. + def record_http_response(span: Any, response: Any) -> None: + pass + try: # note: `#type: ignore` is added because the return type for `should_use_client_cert` # is different than that of the fallback implementation below. This will be removed once 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..ce0c92d09c69 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 @@ -28,7 +28,14 @@ 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.cloud.storagebatchoperations_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.storagebatchoperations_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.cloud.storagebatchoperations_v1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -506,18 +513,29 @@ 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) + ): + 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..2890c72c8555 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,16 @@ 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_methods support +# 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_TRACING = ( + "client_options" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class StorageBatchOperationsTransport(abc.ABC): """Abstract transport class for StorageBatchOperations.""" @@ -57,6 +69,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 +100,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 +140,59 @@ def __init__( host += ':443' self._host = host + self._client_options = client_options self._wrapped_methods: Dict[Callable, Callable] = {} @property def host(self): return self._host + def _wrap_method(self, func, *args, **kwargs): + """Wrap an RPC method with common client-level features. + + Applies retry, timeout, metadata, and tracing wrappers to the + underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + 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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method.wrap_method(func, *args, **kwargs) + + def _wrap_async_method(self, func, *args, **kwargs): + """Wrap an async RPC method with common client-level features. + + Applies asynchronous retry, timeout, metadata, and tracing wrappers + to the underlying RPC method callable. If the runtime `google-api-core` + version supports tracing, transport attributes (`client_options` and + `kind`) are injected. Otherwise, tracing-specific arguments are + stripped for backward compatibility with older `google-api-core` + versions. + """ + if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: + kwargs["client_options"] = self._client_options + if self.kind: + kwargs["kind"] = self.kind + return gapic_v1.method_async.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.). + for k in ["client_options", "method_name", "is_streaming", "kind"]: + kwargs.pop(k, None) + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + def _prep_wrapped_messages(self, client_info): - # Precompute the wrapped methods. + """Precompute and cache wrapped methods for RPC dispatch.""" 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 +205,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 +220,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 +247,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 +262,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 +277,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", ), } @@ -376,7 +448,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( 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..87dae2634941 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,20 @@ 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 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 +from google.cloud.storagebatchoperations_v1._compat import _observability 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 @@ -38,6 +40,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import StorageBatchOperationsTransport, DEFAULT_CLIENT_INFO +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] + + try: from google.api_core import client_logging # type: ignore CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER @@ -138,6 +145,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 +204,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 +266,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 +288,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/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py index 97a7a3213a3c..c294f37e773d 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import inspect import json import pickle import logging as std_logging @@ -25,6 +24,8 @@ from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries from google.api_core import operations_v1 +from google.api_core import client_options as client_options_lib +from google.cloud.storagebatchoperations_v1._compat import _observability 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 @@ -185,6 +186,9 @@ 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[aio.ClientInterceptor]] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -236,6 +240,11 @@ 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[aio.ClientInterceptor]]): + Additional 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 @@ -291,6 +300,8 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, api_audience=api_audience, + client_options=client_options, + **kwargs, ) if not self._grpc_channel: @@ -312,10 +323,52 @@ def __init__(self, *, ], ) + channel_interceptors = list(interceptors) if interceptors else [] self._interceptor = _LoggingClientAIOInterceptor() - self._grpc_channel._unary_unary_interceptors.append(self._interceptor) + channel_interceptors.append(self._interceptor) + + if ( + _observability is not None + and (otel_interceptors := _observability.get_otel_async_interceptor(self._client_options)) is not None + ): + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] + channel_interceptors.extend(otel_list) + + # Fallback for older versions of google-api-core where apply_channel_interceptors is unavailable. + def _fallback_apply_interceptors(channel, interceptors): # pragma: NO COVER + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list) and interceptor not in target_list: + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list) and interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + return channel + + apply_interceptors = getattr( + grpc_helpers_async, + "apply_channel_interceptors", + _fallback_apply_interceptors, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, channel_interceptors) + + # In async gRPC, interceptors must be supplied at channel construction time; + # there is no post-creation interceptor wrapping like sync's grpc.intercept_channel. + # We set self._logged_channel = self._grpc_channel as an alias so that templates + # used for shared stub instantiation (like _mixins.py.j2) wouldn't need + # transport-specific branches. self._logged_channel = self._grpc_channel - self._wrap_with_kind = "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters # Wrap messages. This must be done after self._logged_channel exists self._prep_wrapped_messages(client_info) @@ -528,9 +581,12 @@ def get_bucket_operation(self) -> Callable[ return self._stubs['get_bucket_operation'] def _prep_wrapped_messages(self, client_info): - """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + """Precompute and cache wrapped methods for async RPC dispatch. + + Overrides the base class method to use asynchronous wrappers and retries. + """ self._wrapped_methods = { - self.list_jobs: self._wrap_method( + self.list_jobs: self._wrap_async_method( self.list_jobs, default_retry=retries.AsyncRetry( initial=1.0, @@ -543,8 +599,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: self._wrap_method( + self.get_job: self._wrap_async_method( self.get_job, default_retry=retries.AsyncRetry( initial=1.0, @@ -557,18 +614,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: self._wrap_method( + self.create_job: self._wrap_async_method( self.create_job, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/CreateJob", ), - self.delete_job: self._wrap_method( + self.delete_job: self._wrap_async_method( self.delete_job, default_timeout=60.0, client_info=client_info, + method_name="google.cloud.storagebatchoperations.v1.StorageBatchOperations/DeleteJob", ), - self.cancel_job: self._wrap_method( + self.cancel_job: self._wrap_async_method( self.cancel_job, default_retry=retries.AsyncRetry( initial=1.0, @@ -581,8 +641,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: self._wrap_method( + self.list_bucket_operations: self._wrap_async_method( self.list_bucket_operations, default_retry=retries.AsyncRetry( initial=1.0, @@ -595,8 +656,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: self._wrap_method( + self.get_bucket_operation: self._wrap_async_method( self.get_bucket_operation, default_retry=retries.AsyncRetry( initial=1.0, @@ -609,44 +671,46 @@ 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: self._wrap_method( + self.get_location: self._wrap_async_method( self.get_location, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/GetLocation", ), - self.list_locations: self._wrap_method( + self.list_locations: self._wrap_async_method( self.list_locations, default_timeout=None, client_info=client_info, + method_name="google.cloud.location.Locations/ListLocations", ), - self.cancel_operation: self._wrap_method( + self.cancel_operation: self._wrap_async_method( self.cancel_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/CancelOperation", ), - self.delete_operation: self._wrap_method( + self.delete_operation: self._wrap_async_method( self.delete_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/DeleteOperation", ), - self.get_operation: self._wrap_method( + self.get_operation: self._wrap_async_method( self.get_operation, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/GetOperation", ), - self.list_operations: self._wrap_method( + self.list_operations: self._wrap_async_method( self.list_operations, default_timeout=None, client_info=client_info, + method_name="google.longrunning.Operations/ListOperations", ), } - def _wrap_method(self, func, *args, **kwargs): - if self._wrap_with_kind: # pragma: NO COVER - kwargs["kind"] = self.kind - return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): return self._logged_channel.close() diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest.py index 9a4373457926..5c63ac4374fe 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import contextlib import logging import json # type: ignore @@ -23,7 +24,7 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 -from google.cloud.storagebatchoperations_v1._compat import transcode_request +from google.cloud.storagebatchoperations_v1._compat import transcode_request, trace_http_request, record_http_response import google.protobuf from google.protobuf import json_format @@ -42,6 +43,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseStorageBatchOperationsRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -495,6 +497,7 @@ class StorageBatchOperationsRestStub: _session: AuthorizedSession _host: str _interceptor: StorageBatchOperationsRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class StorageBatchOperationsRestTransport(_BaseStorageBatchOperationsRestTransport): @@ -526,6 +529,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[StorageBatchOperationsRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -569,6 +574,9 @@ 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. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. @@ -580,7 +588,9 @@ def __init__(self, *, client_info=client_info, always_use_jwt_access=always_use_jwt_access, url_scheme=url_scheme, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) self._session = AuthorizedSession( self._credentials, default_host=self.DEFAULT_HOST) @@ -652,20 +662,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.CancelJobRequest, *, @@ -728,7 +752,16 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._CancelJob._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = StorageBatchOperationsRestTransport._CancelJob._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -740,7 +773,6 @@ def __call__(self, pb_resp = storage_batch_operations.CancelJobResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_cancel_job(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_cancel_job_with_metadata(resp, response_metadata) @@ -777,20 +809,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.CreateJobRequest, *, @@ -856,7 +902,16 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._CreateJob._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = StorageBatchOperationsRestTransport._CreateJob._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -866,7 +921,6 @@ def __call__(self, # Return the response resp = operations_pb2.Operation() json_format.Parse(response.content, resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_job(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_job_with_metadata(resp, response_metadata) @@ -903,19 +957,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.DeleteJobRequest, *, @@ -974,7 +1042,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._DeleteJob._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._DeleteJob._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -993,19 +1069,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.GetBucketOperationRequest, *, @@ -1071,7 +1161,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._GetBucketOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._GetBucketOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1083,7 +1181,6 @@ def __call__(self, pb_resp = storage_batch_operations_types.BucketOperation.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_bucket_operation(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_bucket_operation_with_metadata(resp, response_metadata) @@ -1120,19 +1217,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.GetJobRequest, *, @@ -1197,7 +1308,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._GetJob._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._GetJob._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1209,7 +1328,6 @@ def __call__(self, pb_resp = storage_batch_operations_types.Job.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_job(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_job_with_metadata(resp, response_metadata) @@ -1246,19 +1364,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.ListBucketOperationsRequest, *, @@ -1324,7 +1456,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._ListBucketOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._ListBucketOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1336,7 +1476,6 @@ def __call__(self, pb_resp = storage_batch_operations.ListBucketOperationsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_bucket_operations(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_bucket_operations_with_metadata(resp, response_metadata) @@ -1373,19 +1512,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: storage_batch_operations.ListJobsRequest, *, @@ -1448,7 +1601,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._ListJobs._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._ListJobs._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1460,7 +1621,6 @@ def __call__(self, pb_resp = storage_batch_operations.ListJobsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_jobs(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_jobs_with_metadata(resp, response_metadata) @@ -1491,7 +1651,7 @@ def cancel_job(self) -> Callable[ storage_batch_operations.CancelJobResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CancelJob(self._session, self._host, self._interceptor) # type: ignore + return self._CancelJob(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_job(self) -> Callable[ @@ -1499,7 +1659,7 @@ def create_job(self) -> Callable[ operations_pb2.Operation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._CreateJob(self._session, self._host, self._interceptor) # type: ignore + return self._CreateJob(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_job(self) -> Callable[ @@ -1507,7 +1667,7 @@ def delete_job(self) -> Callable[ empty_pb2.Empty]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._DeleteJob(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteJob(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_bucket_operation(self) -> Callable[ @@ -1515,7 +1675,7 @@ def get_bucket_operation(self) -> Callable[ storage_batch_operations_types.BucketOperation]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetBucketOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetBucketOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_job(self) -> Callable[ @@ -1523,7 +1683,7 @@ def get_job(self) -> Callable[ storage_batch_operations_types.Job]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._GetJob(self._session, self._host, self._interceptor) # type: ignore + return self._GetJob(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_bucket_operations(self) -> Callable[ @@ -1531,7 +1691,7 @@ def list_bucket_operations(self) -> Callable[ storage_batch_operations.ListBucketOperationsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListBucketOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListBucketOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_jobs(self) -> Callable[ @@ -1539,11 +1699,11 @@ def list_jobs(self) -> Callable[ storage_batch_operations.ListJobsResponse]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast - return self._ListJobs(self._session, self._host, self._interceptor) # type: ignore + return self._ListJobs(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_location(self): - return self._GetLocation(self._session, self._host, self._interceptor) # type: ignore + return self._GetLocation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetLocation(_BaseStorageBatchOperationsRestTransport._BaseGetLocation, StorageBatchOperationsRestStub): def __hash__(self): @@ -1557,19 +1717,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1632,7 +1806,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._GetLocation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1666,7 +1848,7 @@ def __call__(self, @property def list_locations(self): - return self._ListLocations(self._session, self._host, self._interceptor) # type: ignore + return self._ListLocations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListLocations(_BaseStorageBatchOperationsRestTransport._BaseListLocations, StorageBatchOperationsRestStub): def __hash__(self): @@ -1680,19 +1862,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1755,7 +1951,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._ListLocations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1789,7 +1993,7 @@ def __call__(self, @property def cancel_operation(self): - return self._CancelOperation(self._session, self._host, self._interceptor) # type: ignore + return self._CancelOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _CancelOperation(_BaseStorageBatchOperationsRestTransport._BaseCancelOperation, StorageBatchOperationsRestStub): def __hash__(self): @@ -1803,20 +2007,34 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -1876,7 +2094,16 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = StorageBatchOperationsRestTransport._CancelOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + body, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1887,7 +2114,7 @@ def __call__(self, @property def delete_operation(self): - return self._DeleteOperation(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _DeleteOperation(_BaseStorageBatchOperationsRestTransport._BaseDeleteOperation, StorageBatchOperationsRestStub): def __hash__(self): @@ -1901,19 +2128,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -1973,7 +2214,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._DeleteOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -1984,7 +2233,7 @@ def __call__(self, @property def get_operation(self): - return self._GetOperation(self._session, self._host, self._interceptor) # type: ignore + return self._GetOperation(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _GetOperation(_BaseStorageBatchOperationsRestTransport._BaseGetOperation, StorageBatchOperationsRestStub): def __hash__(self): @@ -1998,19 +2247,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2073,7 +2336,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._GetOperation._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. @@ -2107,7 +2378,7 @@ def __call__(self, @property def list_operations(self): - return self._ListOperations(self._session, self._host, self._interceptor) # type: ignore + return self._ListOperations(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore class _ListOperations(_BaseStorageBatchOperationsRestTransport._BaseListOperations, StorageBatchOperationsRestStub): def __hash__(self): @@ -2121,19 +2392,33 @@ def _get_response( session, timeout, transcoded_request, - body=None): + body=None, + client_options=None): + """Execute the HTTP request over the transport session with + OpenTelemetry tracing and metadata propagation.""" uri = transcoded_request['uri'] method = transcoded_request['method'] headers = dict(metadata) headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, + url = "{host}{uri}".format(host=host, uri=uri) + + with trace_http_request( + client_options=client_options, + method=method, + url=url, + url_template=uri, headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), + body=body, + ) as span: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + record_http_response(span, response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2196,7 +2481,15 @@ def __call__(self, ) # Send the request - response = StorageBatchOperationsRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = StorageBatchOperationsRestTransport._ListOperations._get_response( + self._host, + metadata, + query_params, + self._session, + timeout, + transcoded_request, + client_options=getattr(self, "_client_options", None), + ) # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception # subclass. diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest_base.py index 3066715fa681..9aa639cef740 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/rest_base.py @@ -16,6 +16,7 @@ import json # type: ignore from google.api_core import path_template from google.api_core import gapic_v1 +from google.api_core.client_options import ClientOptions from google.protobuf import json_format from google.cloud.location import locations_pb2 # type: ignore @@ -51,6 +52,8 @@ def __init__(self, *, always_use_jwt_access: Optional[bool] = False, url_scheme: str = 'https', api_audience: Optional[str] = None, + client_options: Optional[Union[ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. Args: @@ -71,6 +74,9 @@ def __init__(self, *, url_scheme: the protocol scheme for the API endpoint. Normally "https", but for testing or local servers, "http" can be specified. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. """ # Run the base constructor maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) @@ -86,7 +92,9 @@ def __init__(self, *, credentials=credentials, client_info=client_info, always_use_jwt_access=always_use_jwt_access, - api_audience=api_audience + api_audience=api_audience, + client_options=client_options, + **kwargs, ) class _BaseCancelJob: diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_compat.py index 94e6e93cf443..4ff91efd4c3c 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_compat.py @@ -25,7 +25,16 @@ import google.auth.transport.mtls from google.cloud.storagebatchoperations_v1._compat import transcode_request -from google.cloud.storagebatchoperations_v1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.cloud.storagebatchoperations_v1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + record_http_response, +) from google.cloud.storagebatchoperations_v1._compat import setup_request_id from google.auth.exceptions import MutualTLSChannelError @@ -528,3 +537,66 @@ def test_read_environment_variables(): with mock.patch.dict(os.environ, {"GOOGLE_API_USE_MTLS_ENDPOINT": "invalid"}): with pytest.raises(MutualTLSChannelError): read_environment_variables() + + +def test_observability_compat(): + # _observability is exposed from _compat + try: + from google.api_core import _observability as core_observability + assert _observability is core_observability + except ImportError: # pragma: NO COVER + assert _observability is None # pragma: NO COVER + + +def test_trace_http_request_compat(): + # trace_http_request is exposed from _compat and callable as context manager + with trace_http_request(method="GET", url="https://example.com") as span: + pass + + +def test_record_http_response_compat(): + # record_http_response is exposed from _compat and callable with dummy args + record_http_response(None, None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.storagebatchoperations_v1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = mock.MagicMock() + mock_obs.record_http_response = mock.MagicMock() + monkeypatch.setitem(sys.modules, "google.api_core._observability", mock_obs) + monkeypatch.setattr(google.api_core, "_observability", mock_obs, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is mock_obs + assert reloaded.trace_http_request is mock_obs.trace_http_request + assert reloaded.record_http_response is mock_obs.record_http_response + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.cloud.storagebatchoperations_v1 import _compat + + # Simulate an environment where google.api_core._observability is not available + monkeypatch.setitem(sys.modules, "google.api_core._observability", None) + monkeypatch.setattr(google.api_core, "_observability", None, raising=False) + reloaded = importlib.reload(_compat) + try: + assert reloaded._observability is None + with reloaded.trace_http_request(method="GET", url="https://example.com") as span: + assert span is None + reloaded.record_http_response(None, None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) 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..e6e09b93a86f 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,194 @@ 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 + + +def test_storage_batch_operations_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.StorageBatchOperationsGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.StorageBatchOperationsGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + assert mock_create_channel.call_count == 1 + assert mock_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_storage_batch_operations_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_async_interceptor.return_value = mock_otel_interceptor + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.grpc_asyncio._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.StorageBatchOperationsGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_obs.get_otel_async_interceptor.assert_called_once_with(options) + assert mock_create_channel.call_count == 1 + assert mock_otel_interceptor in transport.grpc_channel._unary_unary_interceptors + assert transport.grpc_channel == mock_channel + + +def test_storage_batch_operations_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.StorageBatchOperationsGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.StorageBatchOperationsGrpcAsyncIOTransport( + channel=mock_custom_channel, + ) + + assert mock_create_channel.call_count == 0 + 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), @@ -6622,13 +6810,7 @@ def test_storage_batch_operations_base_transport(): with pytest.raises(NotImplementedError): transport.operations_client - # Catch all for all remaining methods and properties - remainder = [ - 'kind', - ] - for r in remainder: - with pytest.raises(NotImplementedError): - getattr(transport, r)() + assert transport.kind == "" def test_storage_batch_operations_base_transport_with_credentials_file(): @@ -6658,6 +6840,94 @@ 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 + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._WRAP_METHOD_SUPPORTS_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 + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + 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 default/empty kind on base transport + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + 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_base_transport_wrap_async_method(): + mock_wrap = mock.Mock() + with mock.patch("google.api_core.gapic_v1.method_async.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_asyncio" + + # Test modern google-api-core with tracing support + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + func = mock.Mock() + transport._wrap_async_method(func) + assert mock_wrap.call_args.kwargs.get("client_options") == options + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Test older google-api-core without tracing support + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, kind="grpc_asyncio") + assert "client_options" not in mock_wrap.call_args.kwargs + assert "kind" not in mock_wrap.call_args.kwargs + + # Test for default/empty kind on base transport + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_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..53f87a05456c 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,15 @@ 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", + ) + parser.addoption( + "--dump-spans", + action="store_true", + default=False, + help="Dump captured raw OpenTelemetry spans to a timestamped JSON file.", ) @@ -153,6 +163,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 +173,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,21 +184,24 @@ 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), + "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( credentials=credentials, host=transport_endpoint, url_scheme="http", + client_options=client_options, ) 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 +354,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 +415,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 +476,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 +494,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/telemetry_requirements_matrix.csv b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv new file mode 100644 index 000000000000..9e6841643553 --- /dev/null +++ b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv @@ -0,0 +1,23 @@ +Feature ID,Feature Name,Tier,Transport,Scenario,Span Count,Span Kind,Span Name,Span Status,rpc.system.name,rpc.method,rpc.response.status_code,http.request.method,http.response.status_code,rpc.grpc.status_code,url.domain,url.template,server.address,server.port,error.type,status.message,resend_count,parent_span_id,Extra / Permitted Attributes +F1.1,Tracing Off (HTTP),T4,HTTP/REST,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted +F1.2,Unary Success (HTTP),T4,HTTP/REST,Happy Path,1,CLIENT,POST,OK,NOT SET,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"url.full, http.request.body.size" +F1.3,Server Error (HTTP),T4,HTTP/REST,Server Failure,1,CLIENT,POST,ERROR,NOT SET,NOT SET,NOT SET,POST,400,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"url.full, http.request.body.size" +F1.4,Client Timeout (HTTP),T4,HTTP/REST,Client Timeout,1,CLIENT,POST,ERROR,NOT SET,NOT SET,NOT SET,POST,504,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full +F1.5,Retry Recovery (HTTP),T4,HTTP/REST,Retry Recovery,2,CLIENT,POST,ERROR | OK,NOT SET,NOT SET,NOT SET,POST,503 | 200,NOT SET,googleapis.com,/v1beta1/sequences/*,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full +F1.6,Tracing Off (gRPC),T4,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted +F1.7,Unary Success (gRPC),T4,gRPC,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.8,Server Error (gRPC),T4,gRPC,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,NOT SET,NOT SET,NOT SET,3,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.9,Client Timeout (gRPC),T4,gRPC,Client Timeout,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,NOT SET,NOT SET,NOT SET,4,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.10,Retry Recovery (gRPC),T4,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR | UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET | OK,NOT SET,NOT SET,14 | 0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F2.1,Tracing Off (HTTP),T3,HTTP/REST,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted +F2.2,Unary Success (HTTP),T3,HTTP/REST,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,http,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) +F2.3,Server Error (HTTP),T3,HTTP/REST,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,BAD_REQUEST,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,BAD_REQUEST,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" +F2.4,Client Timeout (HTTP),T3,HTTP/REST,Client Timeout,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,GATEWAY_TIMEOUT,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,GATEWAY_TIMEOUT,Client deadline exceeded,NOT SET,None (Root),gcp.errors.domain +F2.5,Tracing Off (gRPC),T3,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted +F2.6,Unary Success (gRPC),T3,gRPC,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) +F2.7,Server Error (gRPC),T3,gRPC,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,INVALID_ARGUMENT,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,INVALID_ARGUMENT,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" +F2.8,Client Timeout (gRPC),T3,gRPC,Client Timeout,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,DEADLINE_EXCEEDED,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,DEADLINE_EXCEEDED,Client deadline exceeded,NOT SET,None (Root),gcp.errors.domain +F3.1,Retry Succeeds (HTTP),T3 + T4,HTTP/REST,Retry Recovery,3,CLIENT,T3: google.showcase.v1beta1.SequenceService/AttemptSequence | T4: POST,T3: UNSET | T4: ERROR | OK,T3: http | T4: NOT SET,T3: google.showcase.v1beta1.SequenceService/AttemptSequence | T4: NOT SET,T3: OK | T4: NOT SET,T3: NOT SET | T4: POST,T3: NOT SET | T4: 503 | 200,NOT SET,T3: NOT SET | T4: googleapis.com,T3: NOT SET | T4: /v1beta1/sequences/*,T3: NOT SET | T4: localhost,T3: NOT SET | T4: 7469,NOT SET,NOT SET,NOT SET,T3: None (Root) | T4: T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.2,Retries Exhausted (HTTP),T3 + T4,HTTP/REST,Retries Exhausted,N + 1,CLIENT,T3: google.showcase.v1beta1.SequenceService/AttemptSequence | T4: POST,T3: ERROR | T4: ERROR,T3: http | T4: NOT SET,T3: google.showcase.v1beta1.SequenceService/AttemptSequence | T4: NOT SET,T3: UNAVAILABLE | T4: NOT SET,T3: NOT SET | T4: POST,T3: NOT SET | T4: 503,NOT SET,T3: NOT SET | T4: googleapis.com,T3: NOT SET | T4: /v1beta1/sequences/*,T3: NOT SET | T4: localhost,T3: NOT SET | T4: 7469,T3: UNAVAILABLE | T4: NOT SET,T3: Persistent outage | T4: NOT SET,NOT SET,T3: None (Root) | T4: T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.3,Retry Succeeds (gRPC),T3 + T4,gRPC,Retry Recovery,3,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,T3: UNSET | T4: ERROR | UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,T3: OK | T4: NOT SET | OK,NOT SET,NOT SET,T3: NOT SET | T4: 14 | 0,T3: NOT SET | T4: googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3: None (Root) | T4: T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.4,Retries Exhausted (gRPC),T3 + T4,gRPC,Retries Exhausted,N + 1,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,T3: ERROR | T4: ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,T3: UNAVAILABLE | T4: NOT SET,NOT SET,NOT SET,T3: NOT SET | T4: 14,T3: NOT SET | T4: googleapis.com,NOT SET,NOT SET,NOT SET,T3: UNAVAILABLE | T4: NOT SET,T3: Persistent outage | T4: NOT SET,NOT SET,T3: None (Root) | T4: T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 diff --git a/packages/gapic-generator/tests/system/test_span_compliance.py b/packages/gapic-generator/tests/system/test_span_compliance.py new file mode 100644 index 000000000000..166789ecab94 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_span_compliance.py @@ -0,0 +1,816 @@ +# 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. + +"""Telemetry Semantic Compliance Verification Suite. + +This module implements a spec-driven, matrix-governed verification engine for OpenTelemetry +tracing across Google Cloud Client Libraries (GAPIC), evaluated live against the GAPIC Showcase daemon. + +Architectural Overview: +----------------------- +Rather than hardcoding dozens of repetitive test functions with fragile assertions, this suite +operates as a data-driven test engine governed by a Single Source of Truth (SSOT): +`telemetry_requirements_matrix.csv`. + +The engine operates across five distinct phases: + +1. Specification Matrix (`telemetry_requirements_matrix.csv`): + Defines the contract for all 22 required observability features across transports (gRPC, REST), + span tiers (T3 Logical Client vs. T4 Wire Attempt), error scenarios, and retry sequences. + +2. Scenario Execution & Span Capture (`execute_scenario`): + Instantiates isolated Showcase clients configured with in-memory OpenTelemetry tracer + providers and dispatches live requests (unary echo calls, stateful multi-step retry sequences) + against the Showcase test daemon. + +3. Hierarchy Classification: + Partitions captured finished spans by parentage: + - Root Spans (parent is None): Represents the outer logical RPC call (T3 Tier). + - Child Spans (parent is not None): Represents physical wire attempts (T4 Tier). + Enforces the hierarchical invariant that all T4 spans must link directly to the T3 span ID. + +4. Cell Grammar Parser & Sub-Validators (`resolve_expected_value`, `assert_span_matches_row`): + Parses matrix cell expressions (supporting static values, positional sequences across attempts + such as '503 | 200', and tier-partitioned expressions such as 'T3: OK | T4: NOT SET'). + Validates span names, kinds, status codes, string attributes (with wildcard support), + integer attributes, substring status messages, and protocol-specific resend counts. + +5. Diagnostic Archival (`RAW_SPANS_CATALOG` & `test_z_dump_raw_spans`): + Serializes every captured span in full JSON format to `raw_spans_output.json`, enabling + offline auditability and regression post-mortems without re-running the live server. +""" + +from __future__ import annotations + +import contextlib +import csv +import json +from pathlib import Path +from typing import Any + +import pytest + +try: + 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.client_options import ClientOptions +from google.rpc import code_pb2, status_pb2 +from google.showcase import ( + AttemptSequenceRequest, + CreateSequenceRequest, + EchoClient, + Sequence, + SequenceServiceClient, +) + +try: + from . import conftest +except (ImportError, ValueError): + import conftest + + +# --------------------------------------------------------------------------- +# Test Harness Performance & Tuning Knobs +# --------------------------------------------------------------------------- +# These constants govern execution timing and timeout budgets during test runs. +# They are intentionally separated from the telemetry specification itself: +# while production defaults use exponential backoffs and multi-second retry windows, +# test scenarios require ultra-fast, deterministic execution to keep the CI suite snappy +# without flaking under high concurrency. +SHORT_CLIENT_TIMEOUT_SECONDS = 0.2 +FAST_RETRY_BACKOFF_SECONDS = 0.01 +# Setting multiplier to 1.0 enforces constant/linear polling intervals, bypassing +# exponential backoff delay calculation during retry loops: +CONSTANT_BACKOFF_MULTIPLIER = 1.0 +FAST_EXHAUSTION_DEADLINE_SECONDS = 0.05 +GENEROUS_RECOVERY_DEADLINE_SECONDS = 5.0 +# Queue depth buffer configured on Showcase server for retry exhaustion tests: +SHOWCASE_EXHAUSTION_QUEUE_BUFFER = 20 + + +# --------------------------------------------------------------------------- +# CSV Matrix Loader & Diagnostic Archival +# --------------------------------------------------------------------------- + +CSV_PATH = Path(__file__).parent / "telemetry_requirements_matrix.csv" + + +def load_feature_matrix() -> dict[str, dict[str, str]]: + """Loads and indexes the Single Source of Truth CSV requirements matrix. + + Returns: + A dictionary mapping Feature ID (e.g. 'grpc_happy_path') to its dictionary + of column names and raw string specifications. + """ + with open(CSV_PATH, mode="r", encoding="utf-8") as f: + return {r["Feature ID"]: r for r in csv.DictReader(f)} + + +FEATURE_MATRIX = load_feature_matrix() +RAW_SPANS_CATALOG: dict[str, Any] = {} + + +# --------------------------------------------------------------------------- +# Test Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +def span_exporter(monkeypatch): + """Provides an isolated OpenTelemetry in-memory span exporter and provider. + + Lifecycle: + 1. Instantiates an InMemorySpanExporter coupled to a SimpleSpanProcessor. + 2. Sets the SDK tracing feature flag ('GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED=true') + via pytest's monkeypatch fixture to guarantee process-level isolation without + polluting global environment state. + 3. Yields the (exporter, provider) pair to the test body. + 4. Clears all buffered spans on teardown to prevent state leakage between tests. + + Args: + monkeypatch: Pytest fixture for thread-safe environment variable management. + + Yields: + tuple[InMemorySpanExporter, TracerProvider]: The span collector and provider. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + yield exporter, provider + exporter.clear() + + +# --------------------------------------------------------------------------- +# RPC Invocation Runners & Client Construction +# --------------------------------------------------------------------------- + + +def construct_observability_client( + client_class, + transport: str, + client_options: ClientOptions | None = None, + use_mtls: bool = False, +): + """Factory helper to instantiate GAPIC Showcase clients with test harness defaults. + + This function encapsulates client instantiation by supplying `use_mtls=False` + by default (the standard for local Showcase daemon testing) while forwarding + transport configuration and OpenTelemetry client_options. + + Design Note: + Rather than modifying the shared system test fixture helper (`conftest.construct_client`), + wrapping it locally insulates this compliance suite with zero blast radius on existing + Showcase tests. + + Args: + client_class: The GAPIC client class to instantiate (e.g. EchoClient). + transport: Target transport name ('grpc' or 'rest'). + client_options: Optional ClientOptions containing the TracerProvider. + use_mtls: Whether to enable mTLS (defaults to False). + + Returns: + An instantiated, ready-to-call Showcase client instance. + """ + return conftest.construct_client( + client_class, + use_mtls=use_mtls, + transport_name=transport, + client_options=client_options, + ) + + +def run_echo_call(client: EchoClient, scenario: str): + """Executes unary RPC requests against an injected EchoClient. + + This runner exercises the three standard unary RPC lifecycle scenarios: + - 'Happy Path' / 'Tracing Off': Dispatches a successful unary EchoRequest. + - 'Server Failure': Injects a non-retryable INVALID_ARGUMENT gRPC status on the + Showcase server, expecting an immediate InvalidArgument or BadRequest exception. + - 'Client Timeout': Enforces a tight client-side timeout (`SHORT_CLIENT_TIMEOUT_SECONDS`) + with retries disabled, expecting a DeadlineExceeded or GatewayTimeout exception. + + Args: + client: The instantiated EchoClient (injected by `execute_scenario`). + scenario: The scenario name string from the CSV matrix row. + """ + if scenario in ("Happy Path", "Tracing Off"): + client.echo(showcase.EchoRequest(content="hello")) + elif scenario == "Server Failure": + with pytest.raises((exceptions.InvalidArgument, exceptions.BadRequest)): + client.echo( + showcase.EchoRequest( + error=status_pb2.Status( + code=code_pb2.INVALID_ARGUMENT, + message="Simulated unretryable invalid argument error.", + ) + ) + ) + elif scenario == "Client Timeout": + with pytest.raises((exceptions.DeadlineExceeded, exceptions.GatewayTimeout)): + client.echo( + showcase.EchoRequest( + error=status_pb2.Status( + code=code_pb2.DEADLINE_EXCEEDED, + message="Client deadline exceeded", + ) + ), + timeout=SHORT_CLIENT_TIMEOUT_SECONDS, + retry=None, + ) + + +def run_sequence_retry_call( + client: SequenceServiceClient, + scenario: str, + exporter: InMemorySpanExporter, +): + """Executes stateful retry sequence requests against an injected SequenceServiceClient. + + The Showcase SequenceService allows configuring a server-side queue of pre-programmed + responses that return sequentially across consecutive attempt RPCs. + + Execution Flow: + 1. Pre-programs the server-side sequence queue via `create_sequence`: + - 'Retries Exhausted': Configures an array of UNAVAILABLE ('Persistent outage') responses + and a tight deadline (`FAST_EXHAUSTION_DEADLINE_SECONDS`). + - 'Retry with Recovery': Configures one UNAVAILABLE ('Temporary glitch') response + followed by an OK response, and a generous deadline (`GENEROUS_RECOVERY_DEADLINE_SECONDS`). + 2. Flushes the in-memory span exporter (`exporter.clear()`). + CRITICAL: The `create_sequence` setup call emits its own OpenTelemetry spans! + Purging the exporter ensures that downstream assertions strictly evaluate spans emitted + by the target `attempt_sequence` call. + 3. Invokes `attempt_sequence` with a custom fast Retry policy. + Uses `contextlib.nullcontext()` as a "do-nothing" placeholder so we can run the test + call using a single `with expectation:` block. If we expect the call to fail, `expectation` + is `pytest.raises(...)` to catch the error. If we expect it to succeed, `expectation` is + `nullcontext()`, which just lets the code run normally. This saves us from having to + write out the client call twice! + + Args: + client: The instantiated SequenceServiceClient (injected by `execute_scenario`). + scenario: The scenario name string from the CSV matrix row. + exporter: The active span exporter used to purge pre-flight setup spans. + """ + is_exhaust = scenario == "Retries Exhausted" + + if is_exhaust: + responses = [ + Sequence.Response( + status=status_pb2.Status( + code=code_pb2.UNAVAILABLE, + message="Persistent outage", + ) + ) + ] * SHOWCASE_EXHAUSTION_QUEUE_BUFFER + deadline = FAST_EXHAUSTION_DEADLINE_SECONDS + expectation = pytest.raises( + (exceptions.RetryError, exceptions.ServiceUnavailable) + ) + else: + responses = [ + Sequence.Response( + status=status_pb2.Status( + code=code_pb2.UNAVAILABLE, + message="Temporary glitch", + ) + ), + Sequence.Response(status=status_pb2.Status(code=code_pb2.OK)), + ] + deadline = GENEROUS_RECOVERY_DEADLINE_SECONDS + expectation = contextlib.nullcontext() + + # Step 1: Pre-program response sequence on the Showcase daemon + seq = client.create_sequence( + CreateSequenceRequest(sequence=Sequence(responses=responses)) + ) + + # Step 2: Flush setup RPC spans so assertions evaluate only attempt_sequence + exporter.clear() + + # Step 3: Configure fast retry policy and execute sequence attempts + retry_policy = retries.Retry( + predicate=retries.if_exception_type(exceptions.ServiceUnavailable), + initial=FAST_RETRY_BACKOFF_SECONDS, + maximum=FAST_RETRY_BACKOFF_SECONDS, + multiplier=CONSTANT_BACKOFF_MULTIPLIER, + deadline=deadline, + ) + + with expectation: + client.attempt_sequence( + AttemptSequenceRequest(name=seq.name), + retry=retry_policy, + ) + + +def execute_scenario( + scenario: str, + transport_str: str, + provider: TracerProvider, + exporter: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, +): + """High-level scenario dispatcher and Dependency Injection (DI) coordinator. + + Responsibilities: + 1. Normalizes transport strings ('grpc' vs 'rest'). + 2. Injects the TracerProvider into `ClientOptions` (or unsets it for 'Tracing Off'). + 3. Handles scenario-specific environment configuration via `monkeypatch`. + 4. Instantiates the appropriate client class (`EchoClient` vs `SequenceServiceClient`). + 5. Dispatches execution to the corresponding runner (`run_echo_call` or `run_sequence_retry_call`). + + Args: + scenario: The scenario column value from the CSV matrix row. + transport_str: The transport column value ('gRPC' or 'REST'). + provider: The active OpenTelemetry TracerProvider. + exporter: The active InMemorySpanExporter. + monkeypatch: Pytest monkeypatch fixture for environment variable scoping. + """ + transport = "grpc" if "grpc" in transport_str.lower() else "rest" + client_options = ClientOptions(tracer_provider=provider) + + if scenario == "Tracing Off": + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false") + client_options = ClientOptions() + + if scenario in ("Happy Path", "Server Failure", "Client Timeout", "Tracing Off"): + client = construct_observability_client(EchoClient, transport, client_options) + run_echo_call(client, scenario) + else: + client = construct_observability_client( + SequenceServiceClient, transport, client_options + ) + run_sequence_retry_call(client, scenario, exporter) + + +# --------------------------------------------------------------------------- +# Universal Matrix-Driven Cell Parser & Assertion Engine +# --------------------------------------------------------------------------- + + +def resolve_expected_value( + raw_val: str | None, target_tier: str, attempt_idx: int = 0 +) -> str: + """Resolves the expected value from a CSV cell using the matrix mini-grammar. + + The CSV requirements matrix uses a compact domain-specific language (DSL) to encode + expectations across different span tiers (T3 vs T4) and retry attempt sequences + without exploding the number of columns. + + Supported Syntax Patterns: + -------------------------- + 1. Static Values: + - '200', 'POST', 'INTERNAL', 'NOT SET', 'N/A' + - Directly returns the string literal when the expected value is identical everywhere. + + 2. Positional Sequences (Across Retry Attempts): + - '503 | 200' + - 'ERROR | OK' + - When a call retries, each try (attempt 0, attempt 1, etc.) can produce a different result. + For example, the first try might fail with 503, but the second try succeeds with 200. + - The pipe ('|') separates what we expect on each consecutive try: + * Attempt 0 checks the 1st item ('503'). + * Attempt 1 checks the 2nd item ('200'). + - If there are more attempts than values listed (like a loop that keeps failing 20 times), + it holds onto the last item in the list. + + 3. Tier-Partitioned Expressions (Per-Layer Rules): + - 'T3: OK | T4: NOT SET' + - 'T3: UNSET | T4: ERROR | OK' + - A single row in our matrix often checks an attribute that behaves differently depending + on which layer of the software we look at: + * Tier 3 (T3): The outer, overall operation span. + * Tier 4 (T4): The inner, individual network attempt spans. + - We label each tier with 'T3:' or 'T4:', followed by the value (or positional sequence) + for that tier: + * 'T3: OK | T4: NOT SET' means: "The outer T3 span should be OK, but every inner T4 + attempt span should NOT have this attribute set." + * 'T3: UNSET | T4: ERROR | OK' means: "The outer T3 span should be UNSET, while the T4 + spans will see an ERROR on the 1st try and OK on the 2nd try." + - If a tier is not mentioned in the cell, it returns 'N/A' (meaning that tier doesn't care + about this attribute in this test). + + Args: + raw_val: Raw string content from the CSV cell (or None if empty). + target_tier: The span tier currently being evaluated ('T3' or 'T4'). + attempt_idx: Zero-based attempt index for child T4 spans (defaults to 0 for T3). + + Returns: + The resolved expected string value (e.g. 'OK', 'NOT SET', 'N/A'). + """ + if raw_val is None: + return "N/A" + raw = raw_val.strip() + if not raw or raw == "N/A": + return "N/A" + + # Step 1: Check for tier partitioning (T3: ... | T4: ...) + tier_content = raw + if "T3:" in raw or "T4:" in raw: + if target_tier == "T3": + if "T3:" in raw: + after_t3 = raw.split("T3:")[1] + tier_content = after_t3.split("| T4:")[0].strip() + else: + return "N/A" + elif target_tier == "T4": + if "T4:" in raw: + tier_content = raw.split("T4:")[1].strip() + else: + return "N/A" + + # Step 2: Handle positional sequence across attempts (e.g. '503 | 200') + if "|" in tier_content: + parts = [p.strip() for p in tier_content.split("|")] + return parts[attempt_idx] if attempt_idx < len(parts) else parts[-1] + + return tier_content.strip() + + +# OpenTelemetry attributes validated as strings +STRING_ATTRIBUTES = [ + "rpc.system.name", + "rpc.method", + "rpc.response.status_code", + "http.request.method", + "url.domain", + "url.template", + "server.address", + "error.type", +] + +# OpenTelemetry attributes validated as exact integer types +INTEGER_ATTRIBUTES = [ + "http.response.status_code", + "rpc.grpc.status_code", + "server.port", +] + + +def _assert_attribute( + span, + attr: str, + raw_expected: str | None, + target_tier: str, + attempt_idx: int = 0, + comparator=None, +): + """Universal triage helper for single-attribute validation against a matrix cell. + + This helper standardizes attribute verification across all sub-validators: + - 'N/A': Skips assertion (attribute is irrelevant or not applicable to this scenario). + - 'NOT SET': Enforces negative assertion, verifying the key is completely absent + from `span.attributes`. + - Present values: Asserts presence, then applies either a custom comparator callback + (e.g. for wildcard prefixes or integer parsing) or default strict equality. + + Args: + span: The OpenTelemetry ReadableSpan instance being evaluated. + attr: The attribute name key (e.g. 'rpc.system.name'). + raw_expected: The raw string value from the CSV row cell. + target_tier: The span tier being evaluated ('T3' or 'T4'). + attempt_idx: Zero-based attempt index for child spans (0 for root). + comparator: Optional callable `comparator(actual, expected)` for custom validation. + """ + expected = resolve_expected_value(raw_expected, target_tier, attempt_idx) + if expected == "N/A": + return + + if expected == "NOT SET": + assert attr not in span.attributes, ( + f"Attribute {attr} should NOT be set on {target_tier} attempt {attempt_idx}, " + f"found: {span.attributes.get(attr)}" + ) + return + + actual = span.attributes.get(attr) + assert actual is not None, ( + f"Attribute {attr} missing on {target_tier} attempt {attempt_idx}, " + f"expected '{expected}'" + ) + + if comparator: + comparator(actual, expected) + else: + assert actual == expected, ( + f"Attribute {attr} on {target_tier} attempt {attempt_idx}: " + f"expected '{expected}', got '{actual}'" + ) + + +def _assert_span_metadata( + span, row: dict[str, str], target_tier: str, attempt_idx: int +): + """Validates top-level span header fields (Name, Kind, and Status Code). + + Args: + span: The OpenTelemetry ReadableSpan being validated. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' (root operation) or 'T4' (child attempt). + attempt_idx: Zero-based attempt index. + """ + metadata_fields = ( + ("Span Name", span.name, "Span name"), + ("Span Kind", span.kind.name, "Span kind"), + ("Span Status", span.status.status_code.name, "Span status"), + ) + for col, actual, label in metadata_fields: + expected = resolve_expected_value(row.get(col), target_tier, attempt_idx) + if expected != "N/A": + assert actual == expected, ( + f"{label} mismatch on {target_tier} attempt {attempt_idx}: " + f"expected '{expected}', got '{actual}'" + ) + + +def _assert_string_attributes( + span, row: dict[str, str], target_tier: str, attempt_idx: int +): + """Validates string OpenTelemetry attributes with wildcard prefix support. + + Supports wildcard templates in the matrix (e.g. 'http://localhost:7469/v1beta1/echo:echo/*') + by checking `startswith` when an expected value ends with '/*'. + + Args: + span: The OpenTelemetry ReadableSpan being validated. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' or 'T4'. + attempt_idx: Zero-based attempt index. + """ + + def _match_string(actual: Any, expected: str): + if expected.endswith("/*"): + prefix = expected[:-1] + assert str(actual).startswith(prefix), ( + f"Expected attribute to start with '{prefix}', got '{actual}'" + ) + else: + assert actual == expected, f"Expected '{expected}', got '{actual}'" + + for attr in STRING_ATTRIBUTES: + _assert_attribute( + span, + attr, + row.get(attr), + target_tier, + attempt_idx, + comparator=_match_string, + ) + + +def _assert_integer_attributes( + span, row: dict[str, str], target_tier: str, attempt_idx: int +): + """Validates integer OpenTelemetry attributes (e.g. status codes, ports). + + Enforces strict integer type conversion to prevent false positives where string + values might accidentally pass comparison. + + Args: + span: The OpenTelemetry ReadableSpan being validated. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' or 'T4'. + attempt_idx: Zero-based attempt index. + """ + + def _match_int(actual: Any, expected: str): + assert actual == int(expected), f"Expected {expected}, got {actual}" + + for attr in INTEGER_ATTRIBUTES: + _assert_attribute( + span, + attr, + row.get(attr), + target_tier, + attempt_idx, + comparator=_match_int, + ) + + +def _assert_status_message( + span, row: dict[str, str], target_tier: str, attempt_idx: int +): + """Validates status.message via substring containment matching. + + Because gRPC and REST backends format error message payloads slightly differently, + the matrix specifies key error phrases (e.g. 'Persistent outage') that must appear + inside the captured `status.message` attribute. + + Args: + span: The OpenTelemetry ReadableSpan being validated. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' or 'T4'. + attempt_idx: Zero-based attempt index. + """ + + def _match_message(actual: Any, expected: str): + assert expected in str(actual), ( + f"Expected '{expected}' in status.message '{actual}'" + ) + + _assert_attribute( + span, + "status.message", + row.get("status.message"), + target_tier, + attempt_idx, + comparator=_match_message, + ) + + +def _assert_resend_count(span, row: dict[str, str], target_tier: str, attempt_idx: int): + """Validates retry resend counts across HTTP and gRPC attribute variations. + + Protocol Discrepancies Handled: + - HTTP / REST spans record resend counts under `http.request.resend_count`. + - gRPC spans record resend counts under `gcp.grpc.resend_count`. + + Dynamic Index Token ('attempt_index'): + - Attempt 0 (initial request): Resend count must be NOT SET (initial call is not a retry). + - Attempt N (N > 0): Resend count must equal integer N. + + Args: + span: The OpenTelemetry ReadableSpan being validated. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' or 'T4'. + attempt_idx: Zero-based attempt index. + """ + expected_resend = resolve_expected_value( + row.get("resend_count"), target_tier, attempt_idx + ) + if expected_resend == "attempt_index": + expected_resend = "NOT SET" if attempt_idx == 0 else str(attempt_idx) + + if expected_resend == "NOT SET": + assert "http.request.resend_count" not in span.attributes + assert "gcp.grpc.resend_count" not in span.attributes + elif expected_resend != "N/A": + actual_resend = span.attributes.get( + "http.request.resend_count" + ) or span.attributes.get("gcp.grpc.resend_count") + assert actual_resend is not None, ( + f"Resend count missing on {target_tier} attempt {attempt_idx}, " + f"expected {expected_resend}" + ) + assert actual_resend == int(expected_resend), ( + f"Resend count on {target_tier} attempt {attempt_idx}: " + f"expected {expected_resend}, got {actual_resend}" + ) + + +def assert_span_matches_row( + span, row: dict[str, str], target_tier: str, attempt_idx: int = 0 +): + """Master validator delegating span verification across specialized sub-validators. + + Coordinates verification of: + 1. Top-level Span Metadata (Name, Kind, Status) + 2. String Attributes (System, Method, URL, Error Type) + 3. Integer Attributes (HTTP / gRPC Status Codes, Port) + 4. Status Error Message (Substring containment) + 5. Retry Resend Count (Protocol-aware retry progression) + + Args: + span: The OpenTelemetry ReadableSpan instance being checked. + row: The CSV requirements dictionary for this feature. + target_tier: 'T3' (logical operation) or 'T4' (wire attempt). + attempt_idx: Zero-based attempt index (defaults to 0). + """ + _assert_span_metadata(span, row, target_tier, attempt_idx) + _assert_string_attributes(span, row, target_tier, attempt_idx) + _assert_integer_attributes(span, row, target_tier, attempt_idx) + _assert_status_message(span, row, target_tier, attempt_idx) + _assert_resend_count(span, row, target_tier, attempt_idx) + + +# --------------------------------------------------------------------------- +# Parametrized Test Runner (22 Features) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "feature_id", + list(FEATURE_MATRIX.keys()), + ids=[ + f"{fid}_{FEATURE_MATRIX[fid]['Feature Name'].replace(' ', '_')}" + for fid in FEATURE_MATRIX + ], +) +def test_feature(feature_id: str, span_exporter, monkeypatch): + """Executes a single observability scenario and validates 1-to-1 against matrix specifications. + + Test Lifecycle: + --------------- + 1. Execute Scenario: + Invokes `execute_scenario` to dispatch unary or retry requests to the live Showcase server. + 2. Capture Spans: + Retrieves all finished spans from the in-memory exporter and archives them into + `RAW_SPANS_CATALOG` for diagnostic output. + 3. Handle 'Tracing Off': + If the row specifies 'Span Count = 0', asserts that zero spans were produced. + 4. Span Partitioning: + Partitions captured spans into root spans (`parent is None`, representing T3) + and child spans (`parent is not None`, representing T4). + 5. Evaluate T3 Root Span: + Verifies that exactly 1 root span was emitted (if required by Tier) and validates + its metadata and attributes. + 6. Evaluate T4 Child Spans: + Verifies child span cardinality, evaluates each attempt sequentially against + positional expectations, and validates the parent-child span ID linkage invariant. + + Args: + feature_id: Unique matrix identifier (e.g. 'grpc_happy_path', 'rest_retry_recovery'). + span_exporter: Isolated test fixture yielding (exporter, provider). + monkeypatch: Pytest environment variable isolation fixture. + """ + row = FEATURE_MATRIX[feature_id] + exporter, provider = span_exporter + + # Phase 1: Execute physical scenario against Showcase daemon + execute_scenario(row["Scenario"], row["Transport"], provider, exporter, monkeypatch) + spans = exporter.get_finished_spans() + + # Phase 2: Archive raw spans in memory for end-of-session JSON diagnostic dump + RAW_SPANS_CATALOG[feature_id] = [json.loads(s.to_json()) for s in spans] + + # Phase 3: Verify Tracing Off scenarios emit zero telemetry + if row["Span Count"] == "0": + assert len(spans) == 0, f"Expected 0 spans for {feature_id}, found {len(spans)}" + return + + # Phase 4: Partition captured spans into Root (T3) and Child (T4) attempts + t3_spans = [s for s in spans if s.parent is None] + t4_spans = [s for s in spans if s.parent is not None] + + tier = row["Tier"] + + # Phase 5: Evaluate Root T3 Span (if applicable) + if tier in ("T3", "T3 + T4"): + assert len(t3_spans) == 1, f"Expected 1 T3 root span, got {len(t3_spans)}" + assert_span_matches_row(t3_spans[0], row, target_tier="T3", attempt_idx=0) + + # Phase 6: Evaluate Child T4 Spans (if applicable) + if tier in ("T4", "T3 + T4"): + expected_count = row["Span Count"] + if expected_count.isdigit(): + expected_t4_count = ( + int(expected_count) if tier == "T4" else int(expected_count) - 1 + ) + assert len(t4_spans) == expected_t4_count, ( + f"Expected {expected_t4_count} T4 child spans, got {len(t4_spans)}" + ) + else: + # Dynamic count for retries exhausted (N attempts + 1 root >= 2 children) + assert len(t4_spans) >= 2, ( + f"Expected >=2 T4 child spans, got {len(t4_spans)}" + ) + + for idx, child in enumerate(t4_spans): + assert_span_matches_row(child, row, target_tier="T4", attempt_idx=idx) + # Hierarchy Invariant: child span must point to root T3 span ID as its parent + if t3_spans: + assert child.parent.span_id == t3_spans[0].context.span_id + + +# --------------------------------------------------------------------------- +# Session Teardown: Raw Spans Archival +# --------------------------------------------------------------------------- + + +def test_z_dump_raw_spans(): + """Serializes all captured raw spans to raw_spans_output.json for audit and review. + + Named with 'test_z_' prefix to guarantee it runs after all 22 feature tests have + populated `RAW_SPANS_CATALOG`. This generated JSON file serves as an immutable + diagnostic artifact showing every span's exact attributes, timing, and hierarchy. + """ + output_path = Path(__file__).parent / "raw_spans_output.json" + with open(output_path, "w", encoding="utf-8") as f: + json.dump(RAW_SPANS_CATALOG, f, indent=2) 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..280d3536e892 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_tracing.py @@ -0,0 +1,224 @@ +# 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._feature_gating_helpers import FeatureGatingError +from google.api_core.client_options import ClientOptions +from google.auth import credentials as ga_credentials +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_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..6ea40b4fcf84 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -42,8 +42,9 @@ def is_otel_capabilities_enabled( """Checks if OTel capabilities are enabled and installed. Args: - client_options: The client options object or dictionary. - env_var: The environment variable to check for enablement. + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options + object or dictionary. + env_var (str): The environment variable to check for enablement. Returns: bool: True if enabled and installed, False otherwise. @@ -71,7 +72,8 @@ def _extract_endpoint_attributes( """Extracts server.address, server.port (if non-default), and url.domain from client options if present. Args: - client_options: The client options object or dictionary. + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options + object or dictionary. Returns: dict[str, Any]: A dictionary containing url.domain and, if an api_endpoint is configured, @@ -120,7 +122,8 @@ def _make_grpc_client_request_hook( """Creates an OpenTelemetry gRPC client request hook with optional endpoint attributes. Args: - endpoint_attrs: Optional static endpoint attributes to attach to every span. + endpoint_attrs (Optional[dict[str, Any]]): Optional static endpoint attributes to attach + to every span. Returns: Callable[[Any, Any], None]: The request hook callback. @@ -182,10 +185,10 @@ def _grpc_client_response_hook(span: Any, response: Any) -> None: modern ``rpc.response.status_code`` in future releases, this hook can be retired. Args: - span: The OpenTelemetry span. - response: The gRPC response object or details. + span (Optional[Any]): The OpenTelemetry span. + response (Any): 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. @@ -206,7 +209,8 @@ def _get_tracer_provider( """Extracts the OpenTelemetry tracer provider from client options if present. Args: - client_options: The client options object or dictionary. + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options + object or dictionary. Returns: opentelemetry.trace.TracerProvider | None: The tracer provider if present, @@ -225,8 +229,8 @@ def get_otel_interceptor( """Returns an interceptor callable that wraps a sync gRPC channel with OpenTelemetry tracing. Args: - client_options: The client options object or dictionary used for feature gating - and extracting the tracer provider. + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options + object or dictionary used for feature gating and extracting the tracer provider. Returns: Callable[[grpc.Channel], grpc.Channel] | None: An interceptor callable if OpenTelemetry @@ -249,6 +253,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 @@ -258,8 +263,8 @@ def get_otel_async_interceptor( """Returns async gRPC client interceptors for OpenTelemetry tracing. Args: - client_options: The client options object or dictionary used for feature gating - and extracting the tracer provider. + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options + object or dictionary used for feature gating and extracting the tracer provider. Returns: Sequence[grpc.aio.ClientInterceptor] | None: Instantiated OpenTelemetry async @@ -279,3 +284,270 @@ def get_otel_async_interceptor( request_hook=request_hook, response_hook=_grpc_client_response_hook, ) + + +_TRACE_CONTEXT_PROPAGATOR: Any = None + + +def _get_trace_context_propagator() -> Any: + global _TRACE_CONTEXT_PROPAGATOR + if _TRACE_CONTEXT_PROPAGATOR is None: + from opentelemetry.trace.propagation.tracecontext import ( # type: ignore[import-not-found] + TraceContextTextMapPropagator, + ) + + _TRACE_CONTEXT_PROPAGATOR = TraceContextTextMapPropagator() + return _TRACE_CONTEXT_PROPAGATOR + + +# The HTTP tracing context manager deliberately supports two distinct invocation styles: +# 1. Bundled Request Object: `trace_http_request(request, ...)` +# Used when callers already possess an HTTP request instance (such as +# requests.PreparedRequest or urllib.request.Request) with `.method`, `.url`, etc. +# 2. Unpacked Keyword Arguments: `trace_http_request(method=..., url=..., headers=..., body=...)` +# Used by generated GAPIC REST transports (_shared_macros.j2). +# In GAPIC templates, requests are assembled from local strings and dictionaries before +# hitting the session. Supporting keyword arguments avoids the CPU and memory overhead +# of instantiating a throwaway dummy request object on every single RPC execution. +def _build_http_span_attributes( + request: Any = None, + *, + method: str | None = None, + url: str | None = None, + url_template: str | None = None, + headers: dict[str, Any] | None = None, + body: Any = None, + client_options: ClientOptions | dict[str, Any] | None = None, +) -> tuple[str, dict[str, Any], Any]: + """Extract span name, semantic attributes dictionary, and resolved headers. + + Supports two calling conventions: + - Pass a single `request` object (such as `requests.PreparedRequest`). + - Pass explicit keyword arguments (`method`, `url`, `headers`, `body`, `client_options`). + + Returns: + tuple[str, dict[str, Any], Any]: A tuple of (span_name, attributes, resolved_headers). + """ + # Defensively handle case where client_options was passed as the first positional argument + if ( + isinstance(request, (ClientOptions, dict)) + and client_options is None + and method is not None + ): + client_options = request + request = None + + if request is not None: + resolved_method = getattr(request, "method", "HTTP") or "HTTP" + resolved_url = getattr(request, "url", "") or "" + resolved_headers = getattr(request, "headers", None) + resolved_body = getattr(request, "body", None) + else: + resolved_method = method or "HTTP" + resolved_url = url or "" + resolved_headers = headers + resolved_body = body + + resolved_method = resolved_method.upper() + endpoint_attrs = _extract_endpoint_attributes(client_options) + + server_address = endpoint_attrs.get("server.address") + server_port = endpoint_attrs.get("server.port") + if not server_address and resolved_url: + try: + parsed = urllib.parse.urlsplit(resolved_url) + server_address = parsed.hostname + if not server_port and parsed.port: + server_port = parsed.port + except Exception: # Fail-open on malformed URL parsing + pass + + span_name = resolved_method + span_attributes: dict[str, Any] = { + "http.request.method": resolved_method, + "server.address": server_address or "", + "server.port": server_port or 443, + "url.domain": endpoint_attrs.get("url.domain", "googleapis.com"), + } + if url_template: + span_attributes["url.template"] = url_template + if resolved_url: + span_attributes["url.full"] = resolved_url + + if resolved_body is not None and isinstance(resolved_body, (bytes, str)): + span_attributes["http.request.body.size"] = len(resolved_body) + + return span_name, span_attributes, resolved_headers + + +class trace_http_request: + """Context manager for tracing an HTTP wire request with OpenTelemetry. + + Manages span creation, semantic attribute attachment, W3C traceparent injection, + and automatic error recording on failure without suppressing caller exceptions. + + Supports two calling conventions: + - Pass a single `request` object (such as `requests.PreparedRequest`). + - Pass explicit keyword arguments (`method`, `url`, `headers`, `body`, `client_options`). + """ + + def __init__( + self, + request: Any = None, + *, + method: str | None = None, + url: str | None = None, + url_template: str | None = None, + headers: dict[str, Any] | None = None, + body: Any = None, + client_options: ClientOptions | dict[str, Any] | None = None, + ): + # Defensively handle case where client_options was passed as the first positional argument + if ( + isinstance(request, (ClientOptions, dict)) + and client_options is None + and method is not None + ): + client_options = request + request = None + + self._request = request + self._method = method + self._url = url + self._url_template = url_template + self._headers = headers + self._body = body + self._client_options = client_options + self._span: Any = None + self._cm: Any = None + + def __enter__(self) -> Any: + if not is_otel_capabilities_enabled(self._client_options): + return None + + try: + from opentelemetry import trace + + tracer_provider = _get_tracer_provider(self._client_options) + if tracer_provider is not None: + tracer = tracer_provider.get_tracer("google.api_core") + else: + tracer = trace.get_tracer("google.api_core") + + span_name, span_attributes, resolved_headers = _build_http_span_attributes( + self._request, + method=self._method, + url=self._url, + url_template=self._url_template, + headers=self._headers, + body=self._body, + client_options=self._client_options, + ) + + self._cm = tracer.start_as_current_span( + span_name, + kind=trace.SpanKind.CLIENT, + attributes=span_attributes, + ) + self._span = self._cm.__enter__() + + if resolved_headers is not None and hasattr( + resolved_headers, "__setitem__" + ): + try: + _get_trace_context_propagator().inject(resolved_headers) + except Exception: # Fail-open on header injection failure + pass + + return self._span + except Exception: + # Fail-open: telemetry failures must never disrupt core RPC execution + return None + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if self._span is not None: + try: + if exc_val is not None: + record_http_error(self._span, exc_val) + finally: + self._cm.__exit__(exc_type, exc_val, exc_tb) + # Always return None so caller exceptions are never suppressed + return None + + +def record_http_response(span: Any, response: Any) -> None: + """Record HTTP response attributes on the wire span. + + Args: + span (Optional[Any]): The active OpenTelemetry span. + response (Any): The HTTP response object (e.g. requests.Response). + """ + if span is None or not hasattr(span, "set_attribute"): + return + + try: + from opentelemetry.trace.status import ( # type: ignore[import-not-found] + Status, + StatusCode, + ) + + status_code = getattr( + response, "status_code", getattr(response, "status", None) + ) + if status_code is not None: + span.set_attribute("http.response.status_code", int(status_code)) + if int(status_code) >= 400: + span.set_status(Status(StatusCode.ERROR)) + else: + span.set_status(Status(StatusCode.OK)) + + headers = getattr(response, "headers", None) + if headers and "Content-Length" in headers: + try: + span.set_attribute( + "http.response.body.size", int(headers["Content-Length"]) + ) + except (ValueError, TypeError): + pass + elif hasattr(response, "_content") and response._content is not None: + try: + span.set_attribute("http.response.body.size", len(response._content)) + except Exception: + pass + except Exception: + pass + + +def record_http_error(span: Any, exc: BaseException) -> None: + """Record an HTTP error/exception on the wire span. + + Args: + span (Optional[Any]): The active OpenTelemetry span. + exc (BaseException): The exception raised during dispatch. + """ + if span is None: + return + + try: + from opentelemetry.trace.status import ( # type: ignore[import-not-found] + Status, + StatusCode, + ) + + if hasattr(span, "record_exception"): + span.record_exception(exc) + if hasattr(span, "set_status"): + span.set_status(Status(StatusCode.ERROR)) + if hasattr(span, "set_attribute"): + status_code = getattr(exc, "code", None) or getattr( + exc, "status_code", None + ) + if status_code: + span.set_attribute("error.type", str(status_code)) + else: + span.set_attribute("error.type", exc.__class__.__name__) + msg = str(exc) + if msg: + span.set_attribute("status.message", msg) + except Exception: # Fail-open on error attribute extraction failure + pass 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..b12a0e126a60 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 @@ -254,7 +268,7 @@ class _GapicCallable(object): client_info (Optional[google.api_core.gapic_v1.client_info.ClientInfo]): Client information used for metadata headers. Defaults to None. kind (str): The transport kind for the RPC method. Defaults to "grpc". - Allowed values for OpenTelemetry method tracing are "grpc" and "grpc_asyncio". + Allowed values for OpenTelemetry method tracing are "grpc" and "rest". """ def __init__( @@ -291,7 +305,7 @@ def __init__( self._start_span_fn = None if ( not is_streaming - and kind == "grpc" + and kind in ("grpc", "rest") and method_name is not None and _observability.is_otel_capabilities_enabled(client_options) ): @@ -309,8 +323,9 @@ def __init__( tracer = trace.get_tracer("google.api_core") span_name, _, _ = _extract_rpc_identity(method_name) + is_rest = kind in ("rest", "rest_asyncio") span_attributes = { - "rpc.system.name": "grpc", + "rpc.system.name": "http" if is_rest else "grpc", "rpc.method": span_name, } self._start_span_fn = functools.partial( @@ -358,14 +373,16 @@ def __call__( elif self._default_metadata: kwargs["metadata"] = self._default_metadata - if self._compression is not None: + if compression is not None: kwargs["compression"] = compression span_cm = contextlib.nullcontext() if self._start_span_fn is not None: try: span_cm = self._start_span_fn() - except Exception: + except ( + Exception + ): # Fail-open: proceed without span if tracing initialization fails span_cm = contextlib.nullcontext() with span_cm as span: @@ -489,8 +506,7 @@ def get_topic(name, timeout=None): is_streaming (bool): Whether the RPC method is streaming. Defaults to False. Streaming methods are currently gated and do not generate Tier 3 spans. kind (str): The transport kind for the RPC method. Defaults to "grpc". - Non-gRPC transports (e.g. "rest") are currently gated and do not generate - Tier 3 method spans. + Allowed values for OpenTelemetry method tracing are "grpc" and "rest". Returns: Callable: A new callable that takes optional ``retry``, ``timeout``, diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_async.py b/packages/google-api-core/google/api_core/gapic_v1/method_async.py index d361bf9f961f..e550b4228745 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method_async.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method_async.py @@ -11,25 +11,200 @@ # 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. -"""AsyncIO helpers for wrapping gRPC methods with common functionality. +"""AsyncIO helpers for wrapping gRPC and REST methods with common functionality. This is used by gapic clients to provide common error mapping, retry, timeout, -compression, pagination, and long-running operations to gRPC methods. +compression, pagination, and long-running operations to methods. """ +import asyncio +import contextlib import functools +import inspect -from google.api_core import grpc_helpers_async +from google.api_core import _observability, grpc_helpers_async from google.api_core.gapic_v1 import client_info +from google.api_core.gapic_v1.client_info import METRICS_METADATA_KEY + +# Retain _GapicCallable import for backward compatibility with external packages from google.api_core.gapic_v1.method import ( # noqa: F401 DEFAULT, USE_DEFAULT_METADATA, + _apply_decorators, + _deduplicate_metadata_tokens, + _extract_error_attributes, + _extract_metrics_header, + _extract_rpc_identity, + _extract_status_code, _GapicCallable, ) +from google.api_core.timeout import TimeToDeadlineTimeout _DEFAULT_ASYNC_TRANSPORT_KIND = "grpc_asyncio" +class _AsyncGapicCallable(object): + """Async callable object that wraps an async RPC method with retry, timeout, metadata, and tracing. + + Args: + target (Callable): The low-level async RPC method. + retry (Optional[google.api_core.retry_async.AsyncRetry]): The default retry for the + callable. If ``None``, this callable will not retry by default. + timeout (Optional[Union[google.api_core.timeout.Timeout, float]]): The default timeout for the + callable. If ``None``, this callable will not specify a timeout argument to the + low-level RPC method. + compression (Optional[grpc.Compression]): The default compression for the callable. + If ``None``, this callable will not specify a compression argument to the low-level + RPC method. + metadata (Optional[Sequence[Tuple[str, str]]]): Additional metadata that is + provided to the RPC method on every invocation. This is merged with + any metadata specified during invocation. If ``None``, no + additional metadata will be passed to the RPC method. + client_options (Optional[google.api_core.client_options.ClientOptions]): + Client options used to configure client-level behavior, such as + custom OpenTelemetry tracer providers. Defaults to None. + method_name (Optional[str]): The optional explicit full RPC method name + (e.g. "/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion"). + is_streaming (bool): Whether the RPC method is streaming. Defaults to False. + Note: Streaming methods do not currently generate Tier 3 observability spans. + client_info (Optional[google.api_core.gapic_v1.client_info.ClientInfo]): + Client information used for metadata headers. Defaults to None. + kind (str): The transport kind for the RPC method. Defaults to "grpc_asyncio". + Allowed values for OpenTelemetry method tracing are "grpc_asyncio" and "rest_asyncio". + """ + + def __init__( + self, + target, + retry, + timeout, + compression, + metadata=None, + client_options=None, + method_name=None, + is_streaming=False, + client_info=None, + kind=_DEFAULT_ASYNC_TRANSPORT_KIND, + ): + self._target = target + self._retry = retry + self._timeout = timeout + self._compression = compression + + # Pre-extract the x-goog-api-client header from the initialized metadata. + self._x_goog_api_client, remaining = _extract_metrics_header(metadata) + self._static_metadata = tuple(remaining) + if self._x_goog_api_client: + self._default_metadata = ( + (METRICS_METADATA_KEY, self._x_goog_api_client), + *self._static_metadata, + ) + else: + self._default_metadata = self._static_metadata + + # Configure the OpenTelemetry span factory once at initialization. + self._start_span_fn = None + if ( + not is_streaming + and kind in ("grpc_asyncio", "rest_asyncio") + and method_name is not None + and _observability.is_otel_capabilities_enabled(client_options) + ): + try: + from opentelemetry import trace + + tracer_provider = None + if isinstance(client_options, dict): + tracer_provider = client_options.get("tracer_provider") + elif client_options is not None: + tracer_provider = getattr(client_options, "tracer_provider", None) + if tracer_provider is not None: + tracer = tracer_provider.get_tracer("google.api_core") + else: + tracer = trace.get_tracer("google.api_core") + + span_name, _, _ = _extract_rpc_identity(method_name) + is_rest = kind in ("rest", "rest_asyncio") + span_attributes = { + "rpc.system.name": "http" if is_rest else "grpc", + "rpc.method": span_name, + } + self._start_span_fn = functools.partial( + tracer.start_as_current_span, + span_name, + kind=trace.SpanKind.CLIENT, + attributes=span_attributes, + ) + except (ImportError, AttributeError, TypeError): + # Gracefully disable tracing if OpenTelemetry or custom provider fails + self._start_span_fn = None + + async def __call__( + self, *args, timeout=DEFAULT, retry=DEFAULT, compression=DEFAULT, **kwargs + ): + """Invoke the low-level async RPC with retry, timeout, compression, and metadata.""" + if retry is DEFAULT: + retry = self._retry + + if timeout is DEFAULT: + timeout = self._timeout + + if compression is DEFAULT: + compression = self._compression + + if isinstance(timeout, (int, float)): + timeout = TimeToDeadlineTimeout(timeout=timeout) + + # Apply all applicable decorators. + wrapped_func = _apply_decorators(self._target, [retry, timeout]) + + if user_metadata := kwargs.get("metadata"): + # Add the user agent metadata to the call. + final_metadata = list(self._static_metadata) + user_x_goog, remaining = _extract_metrics_header(user_metadata) + + merged_header = _deduplicate_metadata_tokens( + self._x_goog_api_client, user_x_goog + ) + if merged_header: + final_metadata.append((METRICS_METADATA_KEY, merged_header)) + final_metadata.extend(remaining) + kwargs["metadata"] = final_metadata + elif self._default_metadata: + kwargs["metadata"] = self._default_metadata + + if compression is not None: + kwargs["compression"] = compression + + span_cm = contextlib.nullcontext() + if self._start_span_fn is not None: + try: + span_cm = self._start_span_fn() + except ( + Exception + ): # Fail-open: proceed without span if tracing initialization fails + span_cm = contextlib.nullcontext() + + with span_cm as span: + try: + res = wrapped_func(*args, **kwargs) + if inspect.isawaitable(res): + result = await res + else: + result = res + if span is not None and hasattr(span, "set_attribute"): + span.set_attribute("rpc.response.status_code", "OK") + return result + except (Exception, asyncio.CancelledError) as exc: + if span is not None and hasattr(span, "set_attribute"): + span.set_attribute( + "rpc.response.status_code", _extract_status_code(exc) + ) + for k, v in _extract_error_attributes(exc).items(): + span.set_attribute(k, v) + raise + + def wrap_method( func, default_retry=None, @@ -37,6 +212,10 @@ def wrap_method( default_compression=None, client_info=client_info.DEFAULT_CLIENT_INFO, kind=_DEFAULT_ASYNC_TRANSPORT_KIND, + *, + client_options=None, + method_name=None, + is_streaming=False, ): """Wrap an async RPC method with common behavior. @@ -51,11 +230,16 @@ def wrap_method( metadata = [client_info.to_grpc_metadata()] if client_info is not None else None return functools.wraps(func)( - _GapicCallable( + _AsyncGapicCallable( func, default_retry, default_timeout, default_compression, metadata=metadata, + client_options=client_options, + method_name=method_name, + is_streaming=is_streaming, + client_info=client_info, + kind=kind, ) ) diff --git a/packages/google-api-core/google/api_core/grpc_helpers_async.py b/packages/google-api-core/google/api_core/grpc_helpers_async.py index d1f897901e7a..f0cdd1905a9a 100644 --- a/packages/google-api-core/google/api_core/grpc_helpers_async.py +++ b/packages/google-api-core/google/api_core/grpc_helpers_async.py @@ -21,7 +21,7 @@ import asyncio import functools import warnings -from typing import AsyncGenerator, Generic, Iterator, Optional, TypeVar +from typing import AsyncGenerator, Generic, Iterator, Optional, Sequence, TypeVar import grpc from grpc import aio @@ -308,6 +308,59 @@ def create_channel( ) +def apply_channel_interceptors( + channel: aio.Channel, + interceptors: Optional[Sequence[aio.ClientInterceptor]] = None, +) -> aio.Channel: + """Applies client interceptors to a gRPC AsyncIO channel. + + In grpc.aio, channels maintain internal interceptor lists + (_unary_unary_interceptors, etc.). To preserve the public API contract for + callers who supply their own pre-instantiated ``channel`` object or a custom + channel factory callable (which does not accept ``interceptors``), we attach + interceptors post-instantiation directly to the channel's interceptor lists. + + Args: + channel (aio.Channel): The async gRPC channel to intercept. + interceptors (Optional[Sequence[aio.ClientInterceptor]]): + Additional interceptors to apply to the channel. + + Returns: + aio.Channel: The channel with interceptors attached, or the original channel + if no interceptors were provided. + """ + if not interceptors: + return channel + + mapping = ( + ("intercept_unary_unary", "_unary_unary_interceptors"), + ("intercept_unary_stream", "_unary_stream_interceptors"), + ("intercept_stream_unary", "_stream_unary_interceptors"), + ("intercept_stream_stream", "_stream_stream_interceptors"), + ) + for interceptor in interceptors: + matched = False + for method_name, attr_name in mapping: + if hasattr(interceptor, method_name) and hasattr(channel, attr_name): + target_list = getattr(channel, attr_name) + if isinstance(target_list, list): + if interceptor not in target_list: + target_list.append(interceptor) + matched = True + elif hasattr(target_list, "append"): + target_list.append(interceptor) + matched = True + if not matched and hasattr(channel, "_unary_unary_interceptors"): + unary_interceptors = channel._unary_unary_interceptors + if isinstance(unary_interceptors, list): + if interceptor not in unary_interceptors: + unary_interceptors.append(interceptor) + elif hasattr(unary_interceptors, "append"): + unary_interceptors.append(interceptor) + + return channel + + class FakeUnaryUnaryCall(_WrappedUnaryUnaryCall): """Fake implementation for unary-unary RPCs. diff --git a/packages/google-api-core/tests/asyncio/gapic/test_method_async.py b/packages/google-api-core/tests/asyncio/gapic/test_method_async.py index e410acbdfaab..517f475a76fd 100644 --- a/packages/google-api-core/tests/asyncio/gapic/test_method_async.py +++ b/packages/google-api-core/tests/asyncio/gapic/test_method_async.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import datetime try: @@ -26,6 +27,9 @@ except ImportError: pytest.skip("No GRPC", allow_module_level=True) +from google.api_core import ( + client_options as client_options_lib, +) from google.api_core import ( exceptions, gapic_v1, @@ -274,3 +278,320 @@ async def test_wrap_method_without_wrap_errors(): await wrapped_method() method.assert_not_called() + + +@pytest.fixture(autouse=True) +def set_event_loop(): + try: + asyncio.get_running_loop() + yield + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + loop.close() + asyncio.set_event_loop(None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kwargs,capabilities_enabled", + [ + ( + { + "method_name": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + }, + False, + ), + ({}, True), + ( + { + "method_name": "/google.cloud.secretmanager.v1.SecretManagerService/StreamingRead", + "is_streaming": True, + }, + True, + ), + ( + { + "method_name": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + "kind": "unsupported_transport", + }, + True, + ), + ( + { + "method_name": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + "kind": "rest", + }, + True, + ), + ( + { + "method_name": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + "kind": "grpc", + }, + True, + ), + ], + ids=[ + "disabled_by_flag", + "omitted_method_name", + "streaming_skipped", + "unsupported_kind_skipped", + "sync_rest_kind_skipped", + "sync_grpc_kind_skipped", + ], +) +async def test_wrap_method_async_otel_tracing_skips_span( + monkeypatch, kwargs, capabilities_enabled +): + """Proves that under various gating conditions, no async Tier 3 span is created.""" + mock_target = mock.AsyncMock(return_value="success") + mock_trace = mock.Mock() + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=capabilities_enabled, + ), + mock.patch.dict( + "sys.modules", + { + "opentelemetry": mock.Mock(trace=mock_trace), + "opentelemetry.trace": mock_trace, + }, + ), + ): + wrapped = gapic_v1.method_async.wrap_method(mock_target, **kwargs) + result = await wrapped() + + assert result == "success" + mock_trace.get_tracer.assert_not_called() + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_enabled_success(mock_otel): + """Proves that when OpenTelemetry tracing is enabled and method_name is passed, a T3 client span is started and awaited.""" + mock_target = mock.AsyncMock(return_value="async_success") + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + default_timeout=60, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="grpc_asyncio", + ) + result = await wrapped() + + assert result == "async_success" + mock_otel.tracer.start_as_current_span.assert_called_once_with( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="CLIENT", + attributes={ + "rpc.system.name": "grpc", + "rpc.method": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + }, + ) + mock_otel.span.set_attribute.assert_called_with("rpc.response.status_code", "OK") + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_enabled_rest_asyncio(mock_otel): + """Proves that when kind is 'rest_asyncio', a T3 client span is started.""" + mock_target = mock.AsyncMock(return_value="rest_success") + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + default_timeout=60, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="rest_asyncio", + ) + result = await wrapped() + + assert result == "rest_success" + mock_otel.tracer.start_as_current_span.assert_called_once_with( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + kind="CLIENT", + attributes={ + "rpc.system.name": "http", + "rpc.method": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + }, + ) + mock_otel.span.set_attribute.assert_called_with("rpc.response.status_code", "OK") + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_coroutine_duration(mock_otel): + """Proves that the span remains active across asynchronous awaits and closes only after completion.""" + span_open_during_call = False + + async def delayed_target(*args, **kwargs): + nonlocal span_open_during_call + span_open_during_call = ( + mock_otel.tracer.start_as_current_span.return_value.__enter__.called + and not mock_otel.tracer.start_as_current_span.return_value.__exit__.called + ) + await asyncio.sleep(0.01) + return "delayed_result" + + wrapped = gapic_v1.method_async.wrap_method( + delayed_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + result = await wrapped() + + assert result == "delayed_result" + assert span_open_during_call is True + assert mock_otel.tracer.start_as_current_span.return_value.__exit__.called is True + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_custom_client_options(mock_otel): + """Proves that providing client_options with a custom tracer_provider uses that provider.""" + mock_target = mock.AsyncMock(return_value="success") + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_otel.tracer + + client_options = client_options_lib.ClientOptions(tracer_provider=mock_provider) + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + client_options=client_options, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + result = await wrapped() + + assert result == "success" + mock_provider.get_tracer.assert_called_once_with("google.api_core") + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_dict_client_options(mock_otel): + """Proves that providing a dict with tracer_provider uses that provider.""" + mock_target = mock.AsyncMock(return_value="success") + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_otel.tracer + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + client_options={"tracer_provider": mock_provider}, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + result = await wrapped() + + assert result == "success" + mock_provider.get_tracer.assert_called_once_with("google.api_core") + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_enabled_error(mock_otel): + """Proves that on async error, status code and error attributes are recorded and exception is raised.""" + error = exceptions.NotFound("Secret not found") + mock_target = mock.AsyncMock(side_effect=error) + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/GetSecret", + ) + + with pytest.raises(exceptions.NotFound): + await wrapped() + + mock_otel.span.set_attribute.assert_any_call( + "rpc.response.status_code", "NOT_FOUND" + ) + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_records_gcp_error_attributes(mock_otel): + """Proves that GCP error attributes (domain, reason, metadata) are recorded on the span.""" + error_info = mock.Mock( + domain="googleapis.com", + reason="RESOURCE_NOT_FOUND", + metadata={"service": "secretmanager"}, + ) + error = exceptions.GoogleAPICallError("Resource not found") + error._error_info = error_info + mock_target = mock.AsyncMock(side_effect=error) + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/GetSecret", + ) + + with pytest.raises(exceptions.GoogleAPICallError): + await wrapped() + + mock_otel.span.set_attribute.assert_any_call("gcp.errors.domain", "googleapis.com") + mock_otel.span.set_attribute.assert_any_call("error.type", "RESOURCE_NOT_FOUND") + mock_otel.span.set_attribute.assert_any_call( + "gcp.errors.metadata.service", "secretmanager" + ) + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_import_error(monkeypatch): + """Proves that if opentelemetry fails to import, method execution proceeds gracefully without tracing.""" + mock_target = mock.AsyncMock(return_value="graceful_success") + + with ( + mock.patch( + "google.api_core._observability.is_otel_capabilities_enabled", + return_value=True, + ), + mock.patch.dict("sys.modules", {"opentelemetry": None}), + ): + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + result = await wrapped() + + assert result == "graceful_success" + + +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_start_span_error_bypasses_tracing( + mock_otel, +): + """Proves that if tracer.start_as_current_span throws an exception, the call executes cleanly.""" + mock_otel.tracer.start_as_current_span.side_effect = RuntimeError("Tracing broken") + mock_target = mock.AsyncMock(return_value="resilient_success") + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + result = await wrapped() + + assert result == "resilient_success" + + +@pytest.mark.asyncio +async def test_wrap_method_async_synchronous_return_value(): + """Proves that wrap_method handles callables returning synchronous non-awaitable values.""" + + def sync_callable(*args, **kwargs): + return "synchronous_result" + + wrapped = gapic_v1.method_async.wrap_method(sync_callable, kind="rest_asyncio") + result = await wrapped(mock.sentinel.request) + assert result == "synchronous_result" + + +@pytest.mark.asyncio +async def test_invoke_wrapped_method_with_metadata_and_no_client_info(): + """Proves that wrap_method handles user metadata without client info and without metrics header.""" + fake_call = grpc_helpers_async.FakeUnaryUnaryCall() + method = mock.Mock(spec=aio.UnaryUnaryMultiCallable, return_value=fake_call) + + wrapped_method = gapic_v1.method_async.wrap_method(method, client_info=None) + + await wrapped_method(mock.sentinel.request, metadata=[("custom-header", "val")]) + + method.assert_called_once_with( + mock.sentinel.request, metadata=[("custom-header", "val")] + ) diff --git a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py index dcb09f18fea2..915de50297b3 100644 --- a/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py +++ b/packages/google-api-core/tests/asyncio/test_grpc_helpers_async.py @@ -743,3 +743,92 @@ async def test_fake_stream_unary_call(): await fake_call.wait_for_connection() response = await fake_call assert fake_call.response == response + + +def test_apply_channel_interceptors_none_or_empty(): + channel = mock.Mock() + assert grpc_helpers_async.apply_channel_interceptors(channel, None) is channel + assert grpc_helpers_async.apply_channel_interceptors(channel, []) is channel + + +def test_apply_channel_interceptors_channel_without_attr(): + channel = object() + interceptor = mock.Mock() + assert ( + grpc_helpers_async.apply_channel_interceptors(channel, [interceptor]) is channel + ) + + +def test_apply_channel_interceptors_list(): + interceptor1 = mock.Mock() + interceptor2 = mock.Mock() + channel = mock.Mock() + channel._unary_unary_interceptors = [interceptor1] + + result = grpc_helpers_async.apply_channel_interceptors( + channel, [interceptor1, interceptor2] + ) + assert result is channel + assert channel._unary_unary_interceptors == [interceptor1, interceptor2] + + +def test_apply_channel_interceptors_mock(): + interceptor1 = mock.Mock() + interceptor2 = mock.Mock() + channel = mock.Mock() + channel._unary_unary_interceptors = mock.Mock(spec=["append"]) + + result = grpc_helpers_async.apply_channel_interceptors( + channel, [interceptor1, interceptor2] + ) + assert result is channel + channel._unary_unary_interceptors.append.assert_any_call(interceptor1) + channel._unary_unary_interceptors.append.assert_any_call(interceptor2) + + +def test_apply_channel_interceptors_inner_target_not_appendable(): + channel = mock.Mock(spec=["_unary_unary_interceptors"]) + channel._unary_unary_interceptors = 123 + interceptor = mock.Mock(spec=["intercept_unary_unary"]) + assert ( + grpc_helpers_async.apply_channel_interceptors(channel, [interceptor]) is channel + ) + + +def test_apply_channel_interceptors_fallback_not_appendable(): + channel = mock.Mock(spec=["_unary_unary_interceptors"]) + channel._unary_unary_interceptors = 123 + interceptor = mock.Mock(spec=[]) + assert ( + grpc_helpers_async.apply_channel_interceptors(channel, [interceptor]) is channel + ) + + +def test_apply_channel_interceptors_fallback_unary_unary_list(): + class CustomInterceptor: + pass + + interceptor1 = CustomInterceptor() + interceptor2 = CustomInterceptor() + channel = mock.Mock(spec=["_unary_unary_interceptors"]) + channel._unary_unary_interceptors = [interceptor1] + + result = grpc_helpers_async.apply_channel_interceptors( + channel, [interceptor1, interceptor2] + ) + assert result is channel + assert channel._unary_unary_interceptors == [interceptor1, interceptor2] + + +def test_apply_channel_interceptors_fallback_unary_unary_appendable(): + class CustomInterceptor: + pass + + interceptor = CustomInterceptor() + channel = mock.Mock(spec=["_unary_unary_interceptors"]) + mock_append = mock.Mock(spec=["append"]) + channel._unary_unary_interceptors = mock_append + + result = grpc_helpers_async.apply_channel_interceptors(channel, [interceptor]) + assert result is channel + mock_append.append.assert_called_once_with(interceptor) 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..18a20d8e1879 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method.py +++ b/packages/google-api-core/tests/unit/gapic/test_method.py @@ -377,7 +377,7 @@ def test__deduplicate_metadata_tokens(headers, expected): ( { "method_name": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", - "kind": "rest", + "kind": "custom_unsupported", }, True, ), @@ -407,7 +407,7 @@ def test__deduplicate_metadata_tokens(headers, expected): "disabled_by_flag", "omitted_method_name", "streaming_skipped", - "rest_kind_skipped", + "custom_unsupported_kind_skipped", "rest_asyncio_kind_skipped", "grpc_asyncio_kind_skipped", "http_kind_skipped", @@ -439,7 +439,8 @@ def test_wrap_method_otel_tracing_skips_span(monkeypatch, kwargs, capabilities_e ) -def test_wrap_method_otel_tracing_enabled_success(mock_otel): +@pytest.mark.parametrize("kind", ["grpc", "rest"]) +def test_wrap_method_otel_tracing_enabled_success(mock_otel, kind): """Proves that when OpenTelemetry tracing is enabled and method_name is passed, a T3 client span is started.""" mock_target = mock.Mock(return_value="success") @@ -447,15 +448,19 @@ def test_wrap_method_otel_tracing_enabled_success(mock_otel): mock_target, default_timeout=60, method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", - kind="grpc", + kind=kind, ) result = wrapped() assert result == "success" + expected_attributes = { + "rpc.system.name": "http" if kind == "rest" else "grpc", + "rpc.method": "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + } mock_otel.tracer.start_as_current_span.assert_called_once_with( "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", kind="CLIENT", - attributes=_DEFAULT_SPAN_ATTRIBUTES, + attributes=expected_attributes, ) mock_otel.span.set_attribute.assert_called_with("rpc.response.status_code", "OK") @@ -525,9 +530,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 +556,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 +566,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 +699,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 +759,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 +853,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 +880,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 +927,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..6fc26620fc7d 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -542,3 +542,679 @@ 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 + + +def test_build_http_span_attributes_with_request(): + """Proves that _build_http_span_attributes extracts attributes from a request object.""" + headers = {"key": "val"} + request = mock.Mock( + method="post", + url="https://example.com:8443/v1/echo", + headers=headers, + body=b"bytes-payload", + ) + name, attrs, res_headers = _observability._build_http_span_attributes( + request, url_template="/v1/echo" + ) + assert name == "POST" + assert attrs["http.request.method"] == "POST" + assert attrs["server.address"] == "example.com" + assert attrs["server.port"] == 8443 + assert attrs["url.template"] == "/v1/echo" + assert attrs["url.full"] == "https://example.com:8443/v1/echo" + assert attrs["http.request.body.size"] == len(b"bytes-payload") + assert res_headers is headers + + +def test_build_http_span_attributes_with_kwargs(): + """Proves that _build_http_span_attributes works with explicit kwargs and string body.""" + headers = {"key": "val"} + options = ClientOptions(api_endpoint="custom.endpoint.com:9443") + name, attrs, res_headers = _observability._build_http_span_attributes( + method="get", + url="https://custom.endpoint.com:9443/v1/items", + url_template="/v1/items", + headers=headers, + body="string-body", + client_options=options, + ) + assert name == "GET" + assert attrs["server.address"] == "custom.endpoint.com" + assert attrs["server.port"] == 9443 + assert attrs["http.request.body.size"] == len("string-body") + assert res_headers is headers + + +def test_build_http_span_attributes_first_arg_client_options(): + """Proves that _build_http_span_attributes shifts client_options when passed positionally.""" + options = ClientOptions(api_endpoint="custom.endpoint.com:443") + name, attrs, res_headers = _observability._build_http_span_attributes( + options, + method="GET", + url="https://custom.endpoint.com:443/test", + ) + assert name == "GET" + assert attrs["server.address"] == "custom.endpoint.com" + + +def test_build_http_span_attributes_url_parsing_fallbacks(): + """Proves that _build_http_span_attributes gracefully handles empty or invalid URLs.""" + # Empty url + name, attrs, _ = _observability._build_http_span_attributes(method="DELETE", url="") + assert name == "DELETE" + assert attrs["server.address"] == "" + assert attrs["server.port"] == 443 + + # Malformed URL + with mock.patch("urllib.parse.urlsplit", side_effect=ValueError("boom")): + name, attrs, _ = _observability._build_http_span_attributes( + method="PUT", url="http://[invalid" + ) + assert name == "PUT" + assert attrs["server.address"] == "" + + +def test_trace_http_request_disabled(): + """Proves that trace_http_request yields None when tracing is disabled.""" + request = mock.Mock(method="GET", url="https://example.com/api", headers={}) + with _observability.trace_http_request( + request, client_options=ClientOptions() + ) as span: + assert span is None + + +def test_trace_http_request_active(monkeypatch): + """Proves that trace_http_request creates a span, sets attributes, and injects W3C headers.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_tracer + + mock_otel = mock.MagicMock() + mock_propagator = mock.Mock() + mock_otel.trace.propagation.tracecontext.TraceContextTextMapPropagator.return_value = mock_propagator + monkeypatch.setattr(_observability, "_TRACE_CONTEXT_PROPAGATOR", mock_propagator) + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions( + api_endpoint="custom.googleapis.com:8443", + tracer_provider=mock_provider, + ) + headers = {} + request = mock.Mock( + method="POST", + url="https://custom.googleapis.com:8443/v1/test", + headers=headers, + body=b"test-body", + ) + + with _observability.trace_http_request( + request, url_template="/v1/test", client_options=options + ) as span: + assert span is mock_span + + mock_tracer.start_as_current_span.assert_called_once() + call_args, call_kwargs = mock_tracer.start_as_current_span.call_args + assert call_args[0] == "POST" + attrs = call_kwargs["attributes"] + assert attrs["http.request.method"] == "POST" + assert attrs["server.address"] == "custom.googleapis.com" + assert attrs["server.port"] == 8443 + assert attrs["url.template"] == "/v1/test" + assert attrs["http.request.body.size"] == 9 + mock_propagator.inject.assert_called_once_with(headers) + + +def test_record_http_response_success(monkeypatch): + """Proves that record_http_response records status code and size attributes.""" + mock_span = mock.Mock() + response = mock.Mock(status_code=200, headers={"Content-Length": "42"}) + + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + _observability.record_http_response(mock_span, response) + mock_span.set_attribute.assert_any_call("http.response.status_code", 200) + mock_span.set_attribute.assert_any_call("http.response.body.size", 42) + + +def test_record_http_response_error_status(monkeypatch): + """Proves that record_http_response sets error status on 4xx/5xx responses.""" + mock_span = mock.Mock() + response = mock.Mock(status_code=503, headers={}) + + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + _observability.record_http_response(mock_span, response) + mock_span.set_attribute.assert_any_call("http.response.status_code", 503) + mock_span.set_status.assert_called_once() + + +def test_record_http_error(monkeypatch): + """Proves that record_http_error records exception and error attributes.""" + mock_span = mock.Mock() + exc = ValueError("Network failure") + + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + _observability.record_http_error(mock_span, exc) + mock_span.record_exception.assert_called_once_with(exc) + mock_span.set_status.assert_called_once() + mock_span.set_attribute.assert_any_call("error.type", "ValueError") + mock_span.set_attribute.assert_any_call("status.message", "Network failure") + + +def test_trace_http_request_with_kwargs(monkeypatch): + """Proves that trace_http_request works when invoked using keyword arguments only.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_tracer + + mock_otel = mock.MagicMock() + mock_propagator = mock.Mock() + mock_otel.trace.propagation.tracecontext.TraceContextTextMapPropagator.return_value = mock_propagator + monkeypatch.setattr(_observability, "_TRACE_CONTEXT_PROPAGATOR", mock_propagator) + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions( + api_endpoint="custom.googleapis.com:8443", + tracer_provider=mock_provider, + ) + headers = {} + + with _observability.trace_http_request( + method="post", + url="https://custom.googleapis.com:8443/v1/test", + headers=headers, + body="string-payload", + url_template="/v1/test", + client_options=options, + ) as span: + assert span is mock_span + + call_args, call_kwargs = mock_tracer.start_as_current_span.call_args + assert call_args[0] == "POST" + attrs = call_kwargs["attributes"] + assert attrs["http.request.method"] == "POST" + assert attrs["http.request.body.size"] == len("string-payload") + mock_propagator.inject.assert_called_once_with(headers) + + +def test_trace_http_request_first_arg_client_options(monkeypatch): + """Proves that trace_http_request shifts client_options when passed as first positional arg.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_tracer + + mock_otel = mock.MagicMock() + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions( + api_endpoint="custom.googleapis.com:8443", + tracer_provider=mock_provider, + ) + + with _observability.trace_http_request( + options, + method="GET", + url="https://custom.googleapis.com:8443/v1/test", + ) as span: + assert span is mock_span + + +def test_trace_http_request_default_tracer_and_url_parse(monkeypatch): + """Proves that trace_http_request uses trace.get_tracer when tracer_provider is None, + and extracts server.address and port from url if not present in options. + """ + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions() # No api_endpoint, tracer_provider=None + + with _observability.trace_http_request( + client_options=options, + method="GET", + url="https://parsed-host.org:9443/v1/items", + ) as span: + assert span is mock_span + + mock_otel.trace.get_tracer.assert_called_once_with("google.api_core") + call_args, call_kwargs = mock_tracer.start_as_current_span.call_args + attrs = call_kwargs["attributes"] + assert attrs["server.address"] == "parsed-host.org" + assert attrs["server.port"] == 9443 + + +def test_trace_http_request_propagator_error(monkeypatch): + """Proves that trace_http_request catches propagation errors silently.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_provider = mock.Mock() + mock_provider.get_tracer.return_value = mock_tracer + + mock_otel = mock.MagicMock() + mock_propagator = mock.Mock() + mock_propagator.inject.side_effect = RuntimeError("Propagator failed") + mock_otel.trace.propagation.tracecontext.TraceContextTextMapPropagator.return_value = mock_propagator + monkeypatch.setattr(_observability, "_TRACE_CONTEXT_PROPAGATOR", mock_propagator) + + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions(tracer_provider=mock_provider) + headers = {} + + with _observability.trace_http_request( + client_options=options, + method="GET", + url="https://example.com", + headers=headers, + ) as span: + assert span is mock_span + + +def test_trace_http_request_unexpected_error(monkeypatch): + """Proves that trace_http_request yields None when an unexpected error occurs during setup.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.side_effect = RuntimeError("Unexpected tracer crash") + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions() + with _observability.trace_http_request( + client_options=options, + method="GET", + url="https://example.com", + ) as span: + assert span is None + + +def test_record_http_response_none_or_missing_attribute(): + """Proves that record_http_response handles None or non-span gracefully.""" + _observability.record_http_response(None, mock.Mock()) + _observability.record_http_response(object(), mock.Mock()) + + +def test_record_http_response_content_fallback_and_invalid_content_length(monkeypatch): + """Proves that record_http_response handles invalid Content-Length and falls back to _content.""" + mock_span = mock.Mock() + # Invalid Content-Length string + response_invalid_len = mock.Mock( + status_code=200, headers={"Content-Length": "not-an-int"} + ) + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + _observability.record_http_response(mock_span, response_invalid_len) + mock_span.set_attribute.assert_called_once_with("http.response.status_code", 200) + + mock_span.reset_mock() + # No Content-Length header, but response._content is present + response_with_content = mock.Mock( + status_code=None, headers={}, _content=b"hello-content" + ) + _observability.record_http_response(mock_span, response_with_content) + mock_span.set_attribute.assert_called_once_with( + "http.response.body.size", len(b"hello-content") + ) + + +def test_record_http_response_exception_handled(monkeypatch): + """Proves that record_http_response catches exceptions gracefully.""" + mock_span = mock.Mock() + mock_span.set_attribute.side_effect = RuntimeError("attribute error") + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + # Should not raise + _observability.record_http_response( + mock_span, mock.Mock(status_code=200, headers={}) + ) + + +def test_record_http_error_none_span(): + """Proves that record_http_error handles span=None gracefully.""" + _observability.record_http_error(None, ValueError("test")) + + +def test_record_http_error_with_status_code_and_empty_msg(monkeypatch): + """Proves that record_http_error uses exc.code or exc.status_code when present, + and skips status.message when str(exc) is empty. + """ + mock_span = mock.Mock() + exc = Exception() + exc.code = 404 + + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + _observability.record_http_error(mock_span, exc) + mock_span.set_attribute.assert_any_call("error.type", "404") + # str(exc) is empty, status.message should not be set + calls = [c[0][0] for c in mock_span.set_attribute.call_args_list] + assert "status.message" not in calls + + +def test_record_http_error_exception_handled(monkeypatch): + """Proves that record_http_error catches exceptions gracefully.""" + mock_span = mock.Mock() + mock_span.record_exception.side_effect = RuntimeError("crash") + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + # Should not raise + _observability.record_http_error(mock_span, ValueError("test")) + + +def test_trace_http_request_url_parse_exception(monkeypatch): + """Proves that trace_http_request handles url parsing errors gracefully.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + with mock.patch("urllib.parse.urlsplit", side_effect=ValueError("Invalid URL")): + options = ClientOptions() + with _observability.trace_http_request( + client_options=options, + method="GET", + url="http://[invalid-url", + ) as span: + assert span is mock_span + + +def test_trace_http_request_empty_url(monkeypatch): + """Proves that trace_http_request works when url is empty or None.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions() + with _observability.trace_http_request( + client_options=options, + method="GET", + url="", + ) as span: + assert span is mock_span + + +def test_record_http_response_content_len_error(monkeypatch): + """Proves record_http_response catches errors in response._content length calculation.""" + mock_span = mock.Mock() + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + response = mock.Mock(status_code=200, headers={}) + # Set _content to an object that raises TypeError on len() + response._content = object() + + _observability.record_http_response(mock_span, response) + + +def test_record_http_error_partial_span(monkeypatch): + """Proves that record_http_error handles spans with missing methods.""" + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + # Object lacking record_exception and set_status + class MinimalSpan: + def __init__(self): + self.attrs = {} + + def set_attribute(self, k, v): + self.attrs[k] = v + + span = MinimalSpan() + _observability.record_http_error(span, ValueError("partial span")) + assert span.attrs["error.type"] == "ValueError" + + # Object lacking set_attribute + class NoAttrSpan: + def __init__(self): + self.recorded = False + self.status = None + + def record_exception(self, exc): + self.recorded = True + + def set_status(self, status): + self.status = status + + span2 = NoAttrSpan() + _observability.record_http_error(span2, ValueError("no attr span")) + assert span2.recorded is True + + +def test_record_http_response_no_content_length_and_no_content(monkeypatch): + """Proves that record_http_response handles responses with neither Content-Length nor _content.""" + mock_span = mock.Mock() + mock_status_mod = mock.Mock() + monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) + + response = mock.Mock(spec=["status_code", "headers"], status_code=200, headers={}) + _observability.record_http_response(mock_span, response) + mock_span.set_attribute.assert_called_once_with("http.response.status_code", 200) + + +def test_trace_http_request_records_error_and_reraises(monkeypatch): + """Proves that trace_http_request records error on active span when exception occurs.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + record_error_called = [] + + def mock_record_http_error(span, exc): + record_error_called.append((span, exc)) + + monkeypatch.setattr(_observability, "record_http_error", mock_record_http_error) + + err = RuntimeError("network broke") + with pytest.raises(RuntimeError, match="network broke"): + with _observability.trace_http_request( + method="GET", + url="https://example.com/fail", + headers={}, + ): + raise err + + assert len(record_error_called) == 1 + assert record_error_called[0] == (mock_span, err) + + +def test_trace_http_request_no_multi_yield_bug(monkeypatch): + """Proves that exceptions in caller block cleanly propagate without RuntimeError.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_span = mock.MagicMock() + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + err = ConnectionResetError("connection reset by peer") + with pytest.raises(ConnectionResetError, match="connection reset by peer"): + with _observability.trace_http_request( + method="GET", + url="https://example.com/api", + ): + raise err