diff --git a/.github/workflows/gapic-generator-tests.yml b/.github/workflows/gapic-generator-tests.yml index 9c9128adbed5..c9fa162db280 100644 --- a/.github/workflows/gapic-generator-tests.yml +++ b/.github/workflows/gapic-generator-tests.yml @@ -71,7 +71,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' @@ -142,6 +142,68 @@ jobs: run: | cd packages/gapic-generator nox -s ${{ matrix.target }}-${MATRIX_PYTHON} + - name: Upload Raw Spans Artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: raw-spans-${{ matrix.target }}-${{ matrix.python }}${{ matrix.logging_scope && format('-{0}', matrix.logging_scope) || '' }} + path: packages/gapic-generator/tests/system/raw_spans_output.json + if-no-files-found: ignore + + showcase-canary: + needs: python_config + strategy: + # Run canary tests with local monorepo google-api-core on bookend runtimes + matrix: + python: ["${{ needs.python_config.outputs.oldest_python }}", "${{ needs.python_config.outputs.latest_stable_python }}"] + target: [showcase, showcase_w_rest_async] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Set up Python "${{ matrix.python }}" + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "${{ matrix.python }}" + cache: 'pip' + - name: Install system dependencies. + run: | + sudo apt-get update + sudo apt-get install -y curl pandoc unzip + - name: Install showcase + 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-${SHOWCASE_VERSION}-linux-amd64.tar.gz + cd /usr/src/showcase/ + tar -xf showcase-* + ./gapic-showcase run & + cd - + - name: Install nox. + run: python -m pip install nox + - name: Install protoc + run: | + 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-${PROTOC_VERSION}.zip + cd /usr/src/protoc/ + unzip protoc-${PROTOC_VERSION}.zip + sudo ln -s /usr/src/protoc/bin/protoc /usr/local/bin/protoc + - name: Run showcase canary with local google-api-core. + env: + INSTALL_LOCAL_CORE: "true" + MATRIX_PYTHON: ${{ matrix.python }} + run: | + cd packages/gapic-generator + nox -s ${{ matrix.target }}-${MATRIX_PYTHON} + - name: Upload Raw Spans Artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: raw-spans-canary-${{ matrix.target }}-${{ matrix.python }} + path: packages/gapic-generator/tests/system/raw_spans_output.json + if-no-files-found: ignore showcase-mypy: needs: python_config @@ -210,7 +272,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' @@ -242,7 +304,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' @@ -287,13 +349,13 @@ 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, showcase-mypy, goldens, goldens-prerelease, fragment-snippet, integration] @@ -314,4 +376,3 @@ jobs: exit 1 fi echo "All checks passed or were successfully skipped." - 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/ci/get_package_shards.py b/ci/get_package_shards.py index d0dd4e7d4ea2..782d4bf576aa 100644 --- a/ci/get_package_shards.py +++ b/ci/get_package_shards.py @@ -54,8 +54,11 @@ # extra name 'mssql-pymssql' under strict uv PEP 621 parsing in sqlalchemy==2.1.0rc2, # pulled via global UV_PRERELEASE=allow). Awaiting team feedback on a long-term # solution (e.g. package migration out of the monorepo or adjusting workflow settings). +# NOTE: 'gcp-sphinx-docfx-yaml' is temporarily excluded due to legacy build dependencies +# (unify/untokenize) failing to build under modern setuptools/uv. EXCLUDED_PACKAGES = { "sqlalchemy-bigquery", + "gcp-sphinx-docfx-yaml", } 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 a726567d8996..5f4fa0ed8403 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..68cd18f009d6 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,12 +5,12 @@ """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 os @@ -20,12 +20,9 @@ import uuid import google.protobuf.message {% endif %} -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, 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 +31,117 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..aa69a43aa2c8 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 trace_ctx: + 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 + trace_ctx.record_response(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..b35d605537ef 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,33 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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 +852,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..748d27b43323 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class {{ service.name }}Transport(abc.ABC): """Abstract transport class for {{ service.name }}.""" @@ -75,6 +90,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 +121,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 +164,83 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +263,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 +460,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..c1f0b3ad7ce8 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..fddbd3b05a24 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +480,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 +493,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..8e3b5c815bc1 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 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..9a079cf363bf 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 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 d6880c73605a..f5d0b04d2e96 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 @@ -64,6 +64,7 @@ from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + ser from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }} import {{ service.async_client_name }} {% endif %} from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }} import transports +from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }}.client import _observability from google.api_core import client_options from google.api_core import exceptions as core_exceptions @@ -905,6 +906,192 @@ def test_{{ service.client_name|snake_case }}_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_{{ service.client_name|snake_case }}_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.{{ service.grpc_transport_name }}, "__init__", return_value=None + ) as patched_transport_init, + ): + client = {{ service.client_name }}(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_{{ service.name|snake_case }}_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_{{ service.name|snake_case }}_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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), @@ -1210,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(): @@ -1247,6 +1428,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + 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.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_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + 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.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_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + 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.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_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..07a154b8dcf0 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, + apply_channel_interceptors, +) {% if has_auto_populated_fields %} from {{package_path}}._compat import setup_request_id {% endif %} @@ -531,4 +540,66 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel + {% 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 ec1c643bf037..bb851e035e87 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,9 +405,21 @@ def showcase( """Run the Showcase test suite.""" with showcase_library(session, templates=templates, other_opts=other_opts): + # When opt-in environment variable is set (e.g. in canary CI or local testing), + # install the local google-api-core package from source. + if os.getenv("INSTALL_LOCAL_CORE") == "true": + 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 = [ @@ -436,9 +449,21 @@ def showcase_w_rest_async( with showcase_library( session, templates=templates, other_opts=other_opts, rest_async_io_enabled=True ): + # When opt-in environment variable is set (e.g. in canary CI or local testing), + # install the local google-api-core package from source. + if os.getenv("INSTALL_LOCAL_CORE") == "true": + 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 = [ @@ -498,7 +523,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 +539,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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..b01536ef7f77 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..ed2d8b57c9ef 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class AssetServiceTransport(abc.ABC): """Abstract transport class for AssetService.""" @@ -55,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. @@ -85,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. @@ -122,26 +141,94 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +242,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 +264,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 +280,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 +302,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 +318,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 +334,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 +349,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 +663,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..85ce1be45db3 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..e55c49fda530 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +1083,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 +1114,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 +1136,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 +1152,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 +1174,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 +1190,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 +1206,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 +1221,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..58761ca43b4c 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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..2a171bdccbbc 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 @@ -56,6 +56,7 @@ from google.cloud.asset_v1.services.asset_service import AssetServiceClient from google.cloud.asset_v1.services.asset_service import pagers from google.cloud.asset_v1.services.asset_service import transports +from google.cloud.asset_v1.services.asset_service.client import _observability from google.cloud.asset_v1.types import asset_service from google.cloud.asset_v1.types import assets from google.longrunning import operations_pb2 # type: ignore @@ -760,6 +761,192 @@ def test_asset_service_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_asset_service_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.AssetServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = AssetServiceClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_asset_service_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_asset_service_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +17486,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 +17516,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.asset_v1.services.asset_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..51bc45116a61 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..df5f8f0c19ea 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..178764cbfe6a 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class IAMCredentialsTransport(abc.ABC): """Abstract transport class for IAMCredentials.""" @@ -52,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. @@ -82,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. @@ -119,16 +138,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +227,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 +243,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 +259,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 +275,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 +326,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..ccb8d6943467 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..7183276c2bcf 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +466,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 +485,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 +501,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 +517,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 +533,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..e1c7ffd8dec6 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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..b7ec24b99be7 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..00c60347c230 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 @@ -52,6 +52,7 @@ from google.iam.credentials_v1.services.iam_credentials import IAMCredentialsAsyncClient from google.iam.credentials_v1.services.iam_credentials import IAMCredentialsClient from google.iam.credentials_v1.services.iam_credentials import transports +from google.iam.credentials_v1.services.iam_credentials.client import _observability from google.iam.credentials_v1.types import common from google.oauth2 import service_account import google.auth @@ -750,6 +751,192 @@ def test_iam_credentials_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_iam_credentials_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.IAMCredentialsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IAMCredentialsClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_iam_credentials_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_iam_credentials_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +3900,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 +3930,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.iam.credentials_v1.services.iam_credentials.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..f64e63b2ac1c 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..d2f0864e9046 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class EventarcTransport(abc.ABC): """Abstract transport class for Eventarc.""" @@ -67,6 +82,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 +113,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 +153,368 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +967,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..7f43c20cd461 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..9d7d909b600b 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +1406,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..24db778d1341 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + ) + trace_ctx.record_response(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..7af29f1a250d 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..d44657f5f2e6 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 @@ -56,6 +56,7 @@ from google.cloud.eventarc_v1.services.eventarc import EventarcClient from google.cloud.eventarc_v1.services.eventarc import pagers from google.cloud.eventarc_v1.services.eventarc import transports +from google.cloud.eventarc_v1.services.eventarc.client import _observability from google.cloud.eventarc_v1.types import channel from google.cloud.eventarc_v1.types import channel as gce_channel from google.cloud.eventarc_v1.types import channel_connection @@ -781,6 +782,192 @@ def test_eventarc_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_eventarc_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.EventarcGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EventarcClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_eventarc_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_eventarc_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +30927,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 +30957,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.eventarc_v1.services.eventarc.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..7250f3538070 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..6fc5ef8800fc 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +73,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 +104,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 +144,160 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +312,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 +329,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 +352,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 +369,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 +410,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 +427,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 +456,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 +839,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..11fc2c1a55e4 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..5a63319e96bd 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +1302,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 +1400,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 +1417,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 +1440,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 +1457,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 +1498,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 +1515,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 +1544,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..402ea51a20a2 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..6a96f32bb8ef 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +73,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 +104,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 +144,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +234,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 +251,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 +268,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 +285,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 +302,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 +319,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 +434,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..513d0f05c8e9 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..28a34556c729 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +526,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 +546,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 +563,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 +580,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 +597,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 +614,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 +631,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..df11791e84da 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..1ac3265f5fc3 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +73,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 +104,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 +144,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +234,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 +251,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 +274,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 +291,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 +396,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..00c617b3a6dc 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..24ac73cd72be 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +481,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 +501,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 +518,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 +541,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 +558,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..006fae1f5cb5 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..40da7c665240 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 @@ -50,6 +50,7 @@ from google.cloud.logging_v2.services.config_service_v2 import ConfigServiceV2Client from google.cloud.logging_v2.services.config_service_v2 import pagers from google.cloud.logging_v2.services.config_service_v2 import transports +from google.cloud.logging_v2.services.config_service_v2.client import _observability from google.cloud.logging_v2.types import logging_config from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -736,6 +737,192 @@ def test_config_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_config_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ConfigServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_config_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +12841,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 +12874,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..67e4e492aa31 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 @@ -47,6 +47,7 @@ from google.cloud.logging_v2.services.logging_service_v2 import LoggingServiceV2Client from google.cloud.logging_v2.services.logging_service_v2 import pagers from google.cloud.logging_v2.services.logging_service_v2 import transports +from google.cloud.logging_v2.services.logging_service_v2.client import _observability from google.cloud.logging_v2.types import log_entry from google.cloud.logging_v2.types import logging from google.longrunning import operations_pb2 # type: ignore @@ -737,6 +738,192 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_logging_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +3432,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 +3466,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..a6ce7c08e963 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 @@ -47,6 +47,7 @@ from google.cloud.logging_v2.services.metrics_service_v2 import MetricsServiceV2Client from google.cloud.logging_v2.services.metrics_service_v2 import pagers from google.cloud.logging_v2.services.metrics_service_v2 import transports +from google.cloud.logging_v2.services.metrics_service_v2.client import _observability from google.cloud.logging_v2.types import logging_metrics from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -735,6 +736,192 @@ def test_metrics_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_metrics_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MetricsServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +3232,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 +3266,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..7482cc97e46d 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..6fc5ef8800fc 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ConfigServiceV2Transport(abc.ABC): """Abstract transport class for ConfigServiceV2.""" @@ -58,6 +73,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 +104,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 +144,160 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +312,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 +329,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 +352,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 +369,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 +410,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 +427,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 +456,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 +839,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..11fc2c1a55e4 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..5a63319e96bd 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +1302,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 +1400,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 +1417,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 +1440,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 +1457,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 +1498,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 +1515,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 +1544,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..402ea51a20a2 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..6a96f32bb8ef 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class LoggingServiceV2Transport(abc.ABC): """Abstract transport class for LoggingServiceV2.""" @@ -58,6 +73,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 +104,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 +144,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +234,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 +251,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 +268,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 +285,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 +302,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 +319,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 +434,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..513d0f05c8e9 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..28a34556c729 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +526,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 +546,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 +563,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 +580,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 +597,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 +614,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 +631,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..ded175017283 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..1ac3265f5fc3 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class MetricsServiceV2Transport(abc.ABC): """Abstract transport class for MetricsServiceV2.""" @@ -58,6 +73,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 +104,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 +144,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +234,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 +251,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 +274,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 +291,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 +396,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..00c617b3a6dc 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..24ac73cd72be 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +481,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 +501,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 +518,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 +541,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 +558,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..006fae1f5cb5 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..b518afab2fe7 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 @@ -50,6 +50,7 @@ from google.cloud.logging_v2.services.config_service_v2 import BaseConfigServiceV2Client from google.cloud.logging_v2.services.config_service_v2 import pagers from google.cloud.logging_v2.services.config_service_v2 import transports +from google.cloud.logging_v2.services.config_service_v2.client import _observability from google.cloud.logging_v2.types import logging_config from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -736,6 +737,192 @@ def test_base_config_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_base_config_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseConfigServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_config_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +12841,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 +12874,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.config_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..67e4e492aa31 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 @@ -47,6 +47,7 @@ from google.cloud.logging_v2.services.logging_service_v2 import LoggingServiceV2Client from google.cloud.logging_v2.services.logging_service_v2 import pagers from google.cloud.logging_v2.services.logging_service_v2 import transports +from google.cloud.logging_v2.services.logging_service_v2.client import _observability from google.cloud.logging_v2.types import log_entry from google.cloud.logging_v2.types import logging from google.longrunning import operations_pb2 # type: ignore @@ -737,6 +738,192 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_logging_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +3432,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 +3466,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..ca04d0627dc0 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 @@ -47,6 +47,7 @@ from google.cloud.logging_v2.services.metrics_service_v2 import BaseMetricsServiceV2Client from google.cloud.logging_v2.services.metrics_service_v2 import pagers from google.cloud.logging_v2.services.metrics_service_v2 import transports +from google.cloud.logging_v2.services.metrics_service_v2.client import _observability from google.cloud.logging_v2.types import logging_metrics from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -735,6 +736,192 @@ def test_base_metrics_service_v2_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_base_metrics_service_v2_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseMetricsServiceV2Client(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +3232,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 +3266,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..8c22ec45755b 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..06eb07553f16 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,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. @@ -85,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. @@ -122,104 +141,188 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +502,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..8f468fb31a61 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..60078b84cd3b 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +723,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..b7ef80abc6c9 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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..283506f41a44 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 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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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..a1f90fcc3a41 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 @@ -64,6 +64,7 @@ from google.cloud.redis_v1.services.cloud_redis import CloudRedisClient from google.cloud.redis_v1.services.cloud_redis import pagers from google.cloud.redis_v1.services.cloud_redis import transports +from google.cloud.redis_v1.services.cloud_redis.client import _observability from google.cloud.redis_v1.types import cloud_redis from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -768,6 +769,192 @@ def test_cloud_redis_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_cloud_redis_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_cloud_redis_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +11442,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 +11512,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 +11542,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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 +13380,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..5084ceda8e75 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..58e6bd9773ea 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 @@ -18,7 +18,7 @@ import os import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from google.api_core import path_template from google.api_core.universe import EmptyUniverseError @@ -26,6 +26,110 @@ 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..6b3ccc31dafd 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..e2e42e5cca8d 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class CloudRedisTransport(abc.ABC): """Abstract transport class for CloudRedis.""" @@ -55,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. @@ -85,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. @@ -122,74 +141,152 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +412,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..ce485d4fcb69 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..9471b7688d62 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +547,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..2bee5f67d75c 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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..07d48400ab21 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 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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = await getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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..1df27adf1a2b 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 @@ -64,6 +64,7 @@ from google.cloud.redis_v1.services.cloud_redis import CloudRedisClient from google.cloud.redis_v1.services.cloud_redis import pagers from google.cloud.redis_v1.services.cloud_redis import transports +from google.cloud.redis_v1.services.cloud_redis.client import _observability from google.cloud.redis_v1.types import cloud_redis from google.longrunning import operations_pb2 # type: ignore from google.oauth2 import service_account @@ -768,6 +769,192 @@ def test_cloud_redis_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_cloud_redis_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_cloud_redis_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +6686,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 +6750,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 +6780,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.redis_v1.services.cloud_redis.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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 +8600,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..5084ceda8e75 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, + apply_channel_interceptors, +) from google.auth.exceptions import MutualTLSChannelError from google.api_core.universe import EmptyUniverseError @@ -423,3 +432,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py index d7096741a7f9..6687b6cdc02a 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py @@ -20,18 +20,121 @@ import uuid import google.protobuf.message -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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/showcase/google/showcase_v1beta1/services/compliance/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/client.py index 683036e3ef65..412320ceb4eb 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -471,18 +478,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ComplianceTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/compliance/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/base.py index a7993ecf993f..c0e23faa4ce7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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.showcase_v1beta1 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 @@ -36,6 +38,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ComplianceTransport(abc.ABC): """Abstract transport class for Compliance.""" @@ -55,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. @@ -85,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. @@ -122,109 +141,194 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.repeat_data_body: gapic_v1.method.wrap_method( + self.repeat_data_body: self._wrap_method( self.repeat_data_body, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBody", ), - self.repeat_data_body_info: gapic_v1.method.wrap_method( + self.repeat_data_body_info: self._wrap_method( self.repeat_data_body_info, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyInfo", ), - self.repeat_data_query: gapic_v1.method.wrap_method( + self.repeat_data_query: self._wrap_method( self.repeat_data_query, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataQuery", ), - self.repeat_data_simple_path: gapic_v1.method.wrap_method( + self.repeat_data_simple_path: self._wrap_method( self.repeat_data_simple_path, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataSimplePath", ), - self.repeat_data_path_resource: gapic_v1.method.wrap_method( + self.repeat_data_path_resource: self._wrap_method( self.repeat_data_path_resource, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataPathResource", ), - self.repeat_data_path_trailing_resource: gapic_v1.method.wrap_method( + self.repeat_data_path_trailing_resource: self._wrap_method( self.repeat_data_path_trailing_resource, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataPathTrailingResource", ), - self.repeat_data_body_put: gapic_v1.method.wrap_method( + self.repeat_data_body_put: self._wrap_method( self.repeat_data_body_put, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyPut", ), - self.repeat_data_body_patch: gapic_v1.method.wrap_method( + self.repeat_data_body_patch: self._wrap_method( self.repeat_data_body_patch, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyPatch", ), - self.get_enum: gapic_v1.method.wrap_method( + self.get_enum: self._wrap_method( self.get_enum, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/GetEnum", ), - self.verify_enum: gapic_v1.method.wrap_method( + self.verify_enum: self._wrap_method( self.verify_enum, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/VerifyEnum", ), - 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_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.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.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.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.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.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.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.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", ), } @@ -411,7 +515,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/grpc.py index 15869fd18b57..f7054caecde9 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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.showcase_v1beta1._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 @@ -37,6 +39,11 @@ from google.showcase_v1beta1.types import compliance from .base import ComplianceTransport, 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/compliance/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/grpc_asyncio.py index 15029fab05bf..95d807b82d71 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -628,110 +657,127 @@ def verify_enum(self) -> Callable[ return self._stubs['verify_enum'] 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.repeat_data_body: self._wrap_method( + self.repeat_data_body: self._wrap_async_method( self.repeat_data_body, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBody", ), - self.repeat_data_body_info: self._wrap_method( + self.repeat_data_body_info: self._wrap_async_method( self.repeat_data_body_info, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyInfo", ), - self.repeat_data_query: self._wrap_method( + self.repeat_data_query: self._wrap_async_method( self.repeat_data_query, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataQuery", ), - self.repeat_data_simple_path: self._wrap_method( + self.repeat_data_simple_path: self._wrap_async_method( self.repeat_data_simple_path, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataSimplePath", ), - self.repeat_data_path_resource: self._wrap_method( + self.repeat_data_path_resource: self._wrap_async_method( self.repeat_data_path_resource, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataPathResource", ), - self.repeat_data_path_trailing_resource: self._wrap_method( + self.repeat_data_path_trailing_resource: self._wrap_async_method( self.repeat_data_path_trailing_resource, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataPathTrailingResource", ), - self.repeat_data_body_put: self._wrap_method( + self.repeat_data_body_put: self._wrap_async_method( self.repeat_data_body_put, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyPut", ), - self.repeat_data_body_patch: self._wrap_method( + self.repeat_data_body_patch: self._wrap_async_method( self.repeat_data_body_patch, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/RepeatDataBodyPatch", ), - self.get_enum: self._wrap_method( + self.get_enum: self._wrap_async_method( self.get_enum, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/GetEnum", ), - self.verify_enum: self._wrap_method( + self.verify_enum: self._wrap_async_method( self.verify_enum, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Compliance/VerifyEnum", ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/compliance/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/rest.py index 5bdb4cc46949..547e6b4425a5 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request import google.protobuf from google.protobuf import json_format @@ -41,6 +42,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseComplianceRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -721,6 +723,7 @@ class ComplianceRestStub: _session: AuthorizedSession _host: str _interceptor: ComplianceRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class ComplianceRestTransport(_BaseComplianceRestTransport): @@ -753,6 +756,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[ComplianceRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -796,6 +801,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. @@ -807,7 +815,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) @@ -828,19 +838,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.EnumRequest, *, @@ -903,7 +927,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._GetEnum._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._GetEnum._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. @@ -915,7 +947,6 @@ def __call__(self, pb_resp = compliance.EnumResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_enum(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_enum_with_metadata(resp, response_metadata) @@ -952,20 +983,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1028,7 +1073,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataBody._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._RepeatDataBody._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. @@ -1040,7 +1094,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_body(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_body_with_metadata(resp, response_metadata) @@ -1077,20 +1130,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1153,7 +1220,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataBodyInfo._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._RepeatDataBodyInfo._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. @@ -1165,7 +1241,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_body_info(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_body_info_with_metadata(resp, response_metadata) @@ -1202,20 +1277,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1278,7 +1367,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataBodyPatch._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._RepeatDataBodyPatch._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. @@ -1290,7 +1388,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_body_patch(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_body_patch_with_metadata(resp, response_metadata) @@ -1327,20 +1424,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1403,7 +1514,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataBodyPut._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._RepeatDataBodyPut._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. @@ -1415,7 +1535,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_body_put(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_body_put_with_metadata(resp, response_metadata) @@ -1452,19 +1571,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1527,7 +1660,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataPathResource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._RepeatDataPathResource._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 +1680,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_path_resource(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_path_resource_with_metadata(resp, response_metadata) @@ -1576,19 +1716,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1652,7 +1806,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataPathTrailingResource._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._RepeatDataPathTrailingResource._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. @@ -1664,7 +1826,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_path_trailing_resource(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_path_trailing_resource_with_metadata(resp, response_metadata) @@ -1701,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1776,7 +1951,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataQuery._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._RepeatDataQuery._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. @@ -1788,7 +1971,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_query(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_query_with_metadata(resp, response_metadata) @@ -1825,19 +2007,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.RepeatRequest, *, @@ -1900,7 +2096,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._RepeatDataSimplePath._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._RepeatDataSimplePath._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. @@ -1912,7 +2116,6 @@ def __call__(self, pb_resp = compliance.RepeatResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_repeat_data_simple_path(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_repeat_data_simple_path_with_metadata(resp, response_metadata) @@ -1949,19 +2152,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: compliance.EnumResponse, *, @@ -2024,7 +2241,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._VerifyEnum._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._VerifyEnum._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. @@ -2036,7 +2261,6 @@ def __call__(self, pb_resp = compliance.EnumResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_verify_enum(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_verify_enum_with_metadata(resp, response_metadata) @@ -2067,7 +2291,7 @@ def get_enum(self) -> Callable[ compliance.EnumResponse]: # 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._GetEnum(self._session, self._host, self._interceptor) # type: ignore + return self._GetEnum(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_body(self) -> Callable[ @@ -2075,7 +2299,7 @@ def repeat_data_body(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataBody(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataBody(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_body_info(self) -> Callable[ @@ -2083,7 +2307,7 @@ def repeat_data_body_info(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataBodyInfo(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataBodyInfo(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_body_patch(self) -> Callable[ @@ -2091,7 +2315,7 @@ def repeat_data_body_patch(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataBodyPatch(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataBodyPatch(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_body_put(self) -> Callable[ @@ -2099,7 +2323,7 @@ def repeat_data_body_put(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataBodyPut(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataBodyPut(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_path_resource(self) -> Callable[ @@ -2107,7 +2331,7 @@ def repeat_data_path_resource(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataPathResource(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataPathResource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_path_trailing_resource(self) -> Callable[ @@ -2115,7 +2339,7 @@ def repeat_data_path_trailing_resource(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataPathTrailingResource(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataPathTrailingResource(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_query(self) -> Callable[ @@ -2123,7 +2347,7 @@ def repeat_data_query(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataQuery(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataQuery(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def repeat_data_simple_path(self) -> Callable[ @@ -2131,7 +2355,7 @@ def repeat_data_simple_path(self) -> Callable[ compliance.RepeatResponse]: # 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._RepeatDataSimplePath(self._session, self._host, self._interceptor) # type: ignore + return self._RepeatDataSimplePath(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def verify_enum(self) -> Callable[ @@ -2139,11 +2363,11 @@ def verify_enum(self) -> Callable[ compliance.EnumResponse]: # 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._VerifyEnum(self._session, self._host, self._interceptor) # type: ignore + return self._VerifyEnum(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseComplianceRestTransport._BaseListLocations, ComplianceRestStub): def __hash__(self): @@ -2157,19 +2381,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -2232,7 +2470,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -2266,7 +2512,7 @@ def __call__(self, @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(_BaseComplianceRestTransport._BaseGetLocation, ComplianceRestStub): def __hash__(self): @@ -2280,19 +2526,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -2355,7 +2615,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -2389,7 +2657,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(_BaseComplianceRestTransport._BaseSetIamPolicy, ComplianceRestStub): def __hash__(self): @@ -2403,20 +2671,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -2479,7 +2761,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._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. @@ -2513,7 +2804,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(_BaseComplianceRestTransport._BaseGetIamPolicy, ComplianceRestStub): def __hash__(self): @@ -2527,19 +2818,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -2602,7 +2907,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -2636,7 +2949,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(_BaseComplianceRestTransport._BaseTestIamPermissions, ComplianceRestStub): def __hash__(self): @@ -2650,20 +2963,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -2726,7 +3053,16 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ComplianceRestTransport._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. @@ -2760,7 +3096,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(_BaseComplianceRestTransport._BaseListOperations, ComplianceRestStub): def __hash__(self): @@ -2774,19 +3110,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2849,7 +3199,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -2883,7 +3241,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(_BaseComplianceRestTransport._BaseGetOperation, ComplianceRestStub): def __hash__(self): @@ -2897,19 +3255,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2972,7 +3344,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -3006,7 +3386,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(_BaseComplianceRestTransport._BaseDeleteOperation, ComplianceRestStub): def __hash__(self): @@ -3020,19 +3400,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -3092,7 +3486,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. @@ -3103,7 +3505,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(_BaseComplianceRestTransport._BaseCancelOperation, ComplianceRestStub): def __hash__(self): @@ -3117,19 +3519,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -3189,7 +3605,15 @@ def __call__(self, ) # Send the request - response = ComplianceRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ComplianceRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/rest_base.py index 7e4869308f03..9808248ea8a1 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/compliance/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 @@ -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 _BaseGetEnum: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/client.py index fce703c88043..97716e3efff2 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -482,18 +489,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., EchoTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/echo/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py index 84bb83f0a421..ee5a25b3e611 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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.showcase_v1beta1 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class EchoTransport(abc.ABC): """Abstract transport class for Echo.""" @@ -56,6 +71,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. @@ -86,6 +102,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. @@ -123,16 +142,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.echo: gapic_v1.method.wrap_method( + self.echo: self._wrap_method( self.echo, default_retry=retries.Retry( initial=0.1, @@ -146,18 +231,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Echo", ), - self.echo_error_details: gapic_v1.method.wrap_method( + self.echo_error_details: self._wrap_method( self.echo_error_details, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/EchoErrorDetails", ), - self.fail_echo_with_details: gapic_v1.method.wrap_method( + self.fail_echo_with_details: self._wrap_method( self.fail_echo_with_details, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/FailEchoWithDetails", ), - self.expand: gapic_v1.method.wrap_method( + self.expand: self._wrap_method( self.expand, default_retry=retries.Retry( initial=0.1, @@ -171,18 +259,24 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Expand", + is_streaming=True, ), - self.collect: gapic_v1.method.wrap_method( + self.collect: self._wrap_method( self.collect, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Collect", + is_streaming=True, ), - self.chat: gapic_v1.method.wrap_method( + self.chat: self._wrap_method( self.chat, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Chat", + is_streaming=True, ), - self.paged_expand: gapic_v1.method.wrap_method( + self.paged_expand: self._wrap_method( self.paged_expand, default_retry=retries.Retry( initial=0.1, @@ -196,71 +290,85 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpand", ), - self.paged_expand_legacy: gapic_v1.method.wrap_method( + self.paged_expand_legacy: self._wrap_method( self.paged_expand_legacy, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpandLegacy", ), - self.paged_expand_legacy_mapped: gapic_v1.method.wrap_method( + self.paged_expand_legacy_mapped: self._wrap_method( self.paged_expand_legacy_mapped, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpandLegacyMapped", ), - self.wait: gapic_v1.method.wrap_method( + self.wait: self._wrap_method( self.wait, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Wait", ), - self.block: gapic_v1.method.wrap_method( + self.block: self._wrap_method( self.block, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Block", ), - 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_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.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.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.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.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.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.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.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", ), } @@ -461,7 +569,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py index 1134abedc755..1b77243d5ccc 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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.showcase_v1beta1._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 @@ from google.showcase_v1beta1.types import echo as gs_echo from .base import EchoTransport, 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 @@ -141,6 +148,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. @@ -191,6 +207,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 @@ -247,6 +269,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: @@ -268,6 +291,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py index 94685df6ac45..3123a16eaebc 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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 @@ -188,6 +189,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. @@ -239,6 +243,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 @@ -294,6 +303,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: @@ -315,10 +326,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -670,9 +699,12 @@ def block(self) -> Callable[ return self._stubs['block'] 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.echo: self._wrap_method( + self.echo: self._wrap_async_method( self.echo, default_retry=retries.AsyncRetry( initial=0.1, @@ -686,18 +718,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Echo", ), - self.echo_error_details: self._wrap_method( + self.echo_error_details: self._wrap_async_method( self.echo_error_details, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/EchoErrorDetails", ), - self.fail_echo_with_details: self._wrap_method( + self.fail_echo_with_details: self._wrap_async_method( self.fail_echo_with_details, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/FailEchoWithDetails", ), - self.expand: self._wrap_method( + self.expand: self._wrap_async_method( self.expand, default_retry=retries.AsyncRetry( initial=0.1, @@ -711,18 +746,24 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Expand", + is_streaming=True, ), - self.collect: self._wrap_method( + self.collect: self._wrap_async_method( self.collect, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Collect", + is_streaming=True, ), - self.chat: self._wrap_method( + self.chat: self._wrap_async_method( self.chat, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Chat", + is_streaming=True, ), - self.paged_expand: self._wrap_method( + self.paged_expand: self._wrap_async_method( self.paged_expand, default_retry=retries.AsyncRetry( initial=0.1, @@ -736,79 +777,88 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpand", ), - self.paged_expand_legacy: self._wrap_method( + self.paged_expand_legacy: self._wrap_async_method( self.paged_expand_legacy, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpandLegacy", ), - self.paged_expand_legacy_mapped: self._wrap_method( + self.paged_expand_legacy_mapped: self._wrap_async_method( self.paged_expand_legacy_mapped, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/PagedExpandLegacyMapped", ), - self.wait: self._wrap_method( + self.wait: self._wrap_async_method( self.wait, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Wait", ), - self.block: self._wrap_method( + self.block: self._wrap_async_method( self.block, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Echo/Block", ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/echo/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/rest.py index 5a7f32df07e8..2e68d2d85cb6 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request 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 _BaseEchoRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -678,6 +680,7 @@ class EchoRestStub: _session: AuthorizedSession _host: str _interceptor: EchoRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class EchoRestTransport(_BaseEchoRestTransport): @@ -712,6 +715,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[EchoRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -755,6 +760,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. @@ -766,7 +774,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) @@ -837,20 +847,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.BlockRequest, *, @@ -913,7 +937,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._Block._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._Block._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. @@ -925,7 +958,6 @@ def __call__(self, pb_resp = gs_echo.BlockResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_block(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_block_with_metadata(resp, response_metadata) @@ -988,20 +1020,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.EchoRequest, *, @@ -1071,7 +1117,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._Echo._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._Echo._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. @@ -1083,7 +1138,6 @@ def __call__(self, pb_resp = gs_echo.EchoResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_echo(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_echo_with_metadata(resp, response_metadata) @@ -1120,20 +1174,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.EchoErrorDetailsRequest, *, @@ -1199,7 +1267,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._EchoErrorDetails._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._EchoErrorDetails._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. @@ -1211,7 +1288,6 @@ def __call__(self, pb_resp = gs_echo.EchoErrorDetailsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_echo_error_details(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_echo_error_details_with_metadata(resp, response_metadata) @@ -1248,21 +1324,35 @@ 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, - stream=True, + body=body, + ) as trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + stream=True, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.ExpandRequest, *, @@ -1328,7 +1418,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._Expand._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._Expand._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. @@ -1337,7 +1436,6 @@ def __call__(self, # Return the response resp = rest_streaming.ResponseIterator(response, gs_echo.EchoResponse) - resp = self._interceptor.post_expand(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_expand_with_metadata(resp, response_metadata) @@ -1369,20 +1467,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.FailEchoWithDetailsRequest, *, @@ -1449,7 +1561,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._FailEchoWithDetails._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._FailEchoWithDetails._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. @@ -1461,7 +1582,6 @@ def __call__(self, pb_resp = gs_echo.FailEchoWithDetailsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_fail_echo_with_details(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_fail_echo_with_details_with_metadata(resp, response_metadata) @@ -1498,20 +1618,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.PagedExpandRequest, *, @@ -1577,7 +1711,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._PagedExpand._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._PagedExpand._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. @@ -1589,7 +1732,6 @@ def __call__(self, pb_resp = gs_echo.PagedExpandResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_paged_expand(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_paged_expand_with_metadata(resp, response_metadata) @@ -1626,20 +1768,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.PagedExpandLegacyRequest, *, @@ -1709,7 +1865,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._PagedExpandLegacy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._PagedExpandLegacy._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. @@ -1721,7 +1886,6 @@ def __call__(self, pb_resp = gs_echo.PagedExpandResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_paged_expand_legacy(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_paged_expand_legacy_with_metadata(resp, response_metadata) @@ -1758,20 +1922,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.PagedExpandRequest, *, @@ -1836,7 +2014,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._PagedExpandLegacyMapped._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._PagedExpandLegacyMapped._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. @@ -1848,7 +2035,6 @@ def __call__(self, pb_resp = gs_echo.PagedExpandLegacyMappedResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_paged_expand_legacy_mapped(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_paged_expand_legacy_mapped_with_metadata(resp, response_metadata) @@ -1885,20 +2071,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_echo.WaitRequest, *, @@ -1964,7 +2164,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._Wait._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._Wait._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. @@ -1974,7 +2183,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_wait(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_wait_with_metadata(resp, response_metadata) @@ -2005,7 +2213,7 @@ def block(self) -> Callable[ gs_echo.BlockResponse]: # 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._Block(self._session, self._host, self._interceptor) # type: ignore + return self._Block(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def chat(self) -> Callable[ @@ -2013,7 +2221,7 @@ def chat(self) -> Callable[ gs_echo.EchoResponse]: # 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._Chat(self._session, self._host, self._interceptor) # type: ignore + return self._Chat(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def collect(self) -> Callable[ @@ -2021,7 +2229,7 @@ def collect(self) -> Callable[ gs_echo.EchoResponse]: # 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._Collect(self._session, self._host, self._interceptor) # type: ignore + return self._Collect(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def echo(self) -> Callable[ @@ -2029,7 +2237,7 @@ def echo(self) -> Callable[ gs_echo.EchoResponse]: # 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._Echo(self._session, self._host, self._interceptor) # type: ignore + return self._Echo(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def echo_error_details(self) -> Callable[ @@ -2037,7 +2245,7 @@ def echo_error_details(self) -> Callable[ gs_echo.EchoErrorDetailsResponse]: # 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._EchoErrorDetails(self._session, self._host, self._interceptor) # type: ignore + return self._EchoErrorDetails(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def expand(self) -> Callable[ @@ -2045,7 +2253,7 @@ def expand(self) -> Callable[ gs_echo.EchoResponse]: # 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._Expand(self._session, self._host, self._interceptor) # type: ignore + return self._Expand(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def fail_echo_with_details(self) -> Callable[ @@ -2053,7 +2261,7 @@ def fail_echo_with_details(self) -> Callable[ gs_echo.FailEchoWithDetailsResponse]: # 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._FailEchoWithDetails(self._session, self._host, self._interceptor) # type: ignore + return self._FailEchoWithDetails(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def paged_expand(self) -> Callable[ @@ -2061,7 +2269,7 @@ def paged_expand(self) -> Callable[ gs_echo.PagedExpandResponse]: # 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._PagedExpand(self._session, self._host, self._interceptor) # type: ignore + return self._PagedExpand(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def paged_expand_legacy(self) -> Callable[ @@ -2069,7 +2277,7 @@ def paged_expand_legacy(self) -> Callable[ gs_echo.PagedExpandResponse]: # 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._PagedExpandLegacy(self._session, self._host, self._interceptor) # type: ignore + return self._PagedExpandLegacy(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def paged_expand_legacy_mapped(self) -> Callable[ @@ -2077,7 +2285,7 @@ def paged_expand_legacy_mapped(self) -> Callable[ gs_echo.PagedExpandLegacyMappedResponse]: # 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._PagedExpandLegacyMapped(self._session, self._host, self._interceptor) # type: ignore + return self._PagedExpandLegacyMapped(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def wait(self) -> Callable[ @@ -2085,11 +2293,11 @@ def wait(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._Wait(self._session, self._host, self._interceptor) # type: ignore + return self._Wait(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseEchoRestTransport._BaseListLocations, EchoRestStub): def __hash__(self): @@ -2103,19 +2311,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -2178,7 +2400,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -2212,7 +2442,7 @@ def __call__(self, @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(_BaseEchoRestTransport._BaseGetLocation, EchoRestStub): def __hash__(self): @@ -2226,19 +2456,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -2301,7 +2545,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -2335,7 +2587,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(_BaseEchoRestTransport._BaseSetIamPolicy, EchoRestStub): def __hash__(self): @@ -2349,20 +2601,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -2425,7 +2691,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._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. @@ -2459,7 +2734,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(_BaseEchoRestTransport._BaseGetIamPolicy, EchoRestStub): def __hash__(self): @@ -2473,19 +2748,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -2548,7 +2837,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -2582,7 +2879,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(_BaseEchoRestTransport._BaseTestIamPermissions, EchoRestStub): def __hash__(self): @@ -2596,20 +2893,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -2672,7 +2983,16 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = EchoRestTransport._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. @@ -2706,7 +3026,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(_BaseEchoRestTransport._BaseListOperations, EchoRestStub): def __hash__(self): @@ -2720,19 +3040,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2795,7 +3129,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -2829,7 +3171,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(_BaseEchoRestTransport._BaseGetOperation, EchoRestStub): def __hash__(self): @@ -2843,19 +3185,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2918,7 +3274,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -2952,7 +3316,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(_BaseEchoRestTransport._BaseDeleteOperation, EchoRestStub): def __hash__(self): @@ -2966,19 +3330,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -3038,7 +3416,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. @@ -3049,7 +3435,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(_BaseEchoRestTransport._BaseCancelOperation, EchoRestStub): def __hash__(self): @@ -3063,19 +3449,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -3135,7 +3535,15 @@ def __call__(self, ) # Send the request - response = EchoRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = EchoRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/rest_base.py index 35ef144c12ea..fdcafa2f4288 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/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 @@ -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 _BaseBlock: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/client.py index 3bbd7db450a9..05b36f7a5768 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -478,18 +485,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., IdentityTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/identity/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py index 3d8c1973d646..3a3429496db1 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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.showcase_v1beta1 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class IdentityTransport(abc.ABC): """Abstract transport class for Identity.""" @@ -56,6 +71,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. @@ -86,6 +102,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. @@ -123,21 +142,88 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.create_user: gapic_v1.method.wrap_method( + self.create_user: self._wrap_method( self.create_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/CreateUser", ), - self.get_user: gapic_v1.method.wrap_method( + self.get_user: self._wrap_method( self.get_user, default_retry=retries.Retry( initial=0.2, @@ -151,18 +237,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=5.0, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/GetUser", ), - self.update_user: gapic_v1.method.wrap_method( + self.update_user: self._wrap_method( self.update_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/UpdateUser", ), - self.delete_user: gapic_v1.method.wrap_method( + self.delete_user: self._wrap_method( self.delete_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/DeleteUser", ), - self.list_users: gapic_v1.method.wrap_method( + self.list_users: self._wrap_method( self.list_users, default_retry=retries.Retry( initial=0.2, @@ -176,51 +265,61 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=5.0, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/ListUsers", ), - 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_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.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.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.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.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.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.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.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", ), } @@ -362,7 +461,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py index 332e019bd35c..8b304a6e46ea 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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.showcase_v1beta1._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 IdentityTransport, 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 @@ -134,6 +141,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. @@ -184,6 +200,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 @@ -239,6 +261,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: @@ -260,6 +283,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py index 0b2ce53e949b..8f1240b3fcea 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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 @@ -181,6 +182,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. @@ -232,6 +236,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 @@ -286,6 +295,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: @@ -307,10 +318,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -456,14 +485,18 @@ def list_users(self) -> Callable[ return self._stubs['list_users'] 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.create_user: self._wrap_method( + self.create_user: self._wrap_async_method( self.create_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/CreateUser", ), - self.get_user: self._wrap_method( + self.get_user: self._wrap_async_method( self.get_user, default_retry=retries.AsyncRetry( initial=0.2, @@ -477,18 +510,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=5.0, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/GetUser", ), - self.update_user: self._wrap_method( + self.update_user: self._wrap_async_method( self.update_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/UpdateUser", ), - self.delete_user: self._wrap_method( + self.delete_user: self._wrap_async_method( self.delete_user, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/DeleteUser", ), - self.list_users: self._wrap_method( + self.list_users: self._wrap_async_method( self.list_users, default_retry=retries.AsyncRetry( initial=0.2, @@ -502,59 +538,64 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=5.0, client_info=client_info, + method_name="google.showcase.v1beta1.Identity/ListUsers", ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/identity/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/rest.py index 252e47dbdb04..36453178ecda 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request 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 _BaseIdentityRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -470,6 +472,7 @@ class IdentityRestStub: _session: AuthorizedSession _host: str _interceptor: IdentityRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class IdentityRestTransport(_BaseIdentityRestTransport): @@ -497,6 +500,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[IdentityRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -540,6 +545,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. @@ -551,7 +559,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) @@ -572,20 +582,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: identity.CreateUserRequest, *, @@ -650,7 +674,16 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._CreateUser._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IdentityRestTransport._CreateUser._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. @@ -662,7 +695,6 @@ def __call__(self, pb_resp = identity.User.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_user(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_user_with_metadata(resp, response_metadata) @@ -699,19 +731,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: identity.DeleteUserRequest, *, @@ -772,7 +818,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._DeleteUser._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._DeleteUser._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. @@ -791,19 +845,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: identity.GetUserRequest, *, @@ -868,7 +936,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._GetUser._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._GetUser._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. @@ -880,7 +956,6 @@ def __call__(self, pb_resp = identity.User.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_user(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_user_with_metadata(resp, response_metadata) @@ -917,19 +992,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: identity.ListUsersRequest, *, @@ -997,7 +1086,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._ListUsers._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._ListUsers._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. @@ -1009,7 +1106,6 @@ def __call__(self, pb_resp = identity.ListUsersResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_users(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_users_with_metadata(resp, response_metadata) @@ -1046,20 +1142,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: identity.UpdateUserRequest, *, @@ -1124,7 +1234,16 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._UpdateUser._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IdentityRestTransport._UpdateUser._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. @@ -1136,7 +1255,6 @@ def __call__(self, pb_resp = identity.User.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_user(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_user_with_metadata(resp, response_metadata) @@ -1167,7 +1285,7 @@ def create_user(self) -> Callable[ identity.User]: # 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._CreateUser(self._session, self._host, self._interceptor) # type: ignore + return self._CreateUser(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_user(self) -> Callable[ @@ -1175,7 +1293,7 @@ def delete_user(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._DeleteUser(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteUser(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_user(self) -> Callable[ @@ -1183,7 +1301,7 @@ def get_user(self) -> Callable[ identity.User]: # 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._GetUser(self._session, self._host, self._interceptor) # type: ignore + return self._GetUser(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_users(self) -> Callable[ @@ -1191,7 +1309,7 @@ def list_users(self) -> Callable[ identity.ListUsersResponse]: # 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._ListUsers(self._session, self._host, self._interceptor) # type: ignore + return self._ListUsers(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_user(self) -> Callable[ @@ -1199,11 +1317,11 @@ def update_user(self) -> Callable[ identity.User]: # 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._UpdateUser(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateUser(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseIdentityRestTransport._BaseListLocations, IdentityRestStub): def __hash__(self): @@ -1217,19 +1335,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1292,7 +1424,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -1326,7 +1466,7 @@ def __call__(self, @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(_BaseIdentityRestTransport._BaseGetLocation, IdentityRestStub): def __hash__(self): @@ -1340,19 +1480,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1415,7 +1569,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -1449,7 +1611,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(_BaseIdentityRestTransport._BaseSetIamPolicy, IdentityRestStub): def __hash__(self): @@ -1463,20 +1625,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -1539,7 +1715,16 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IdentityRestTransport._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. @@ -1573,7 +1758,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(_BaseIdentityRestTransport._BaseGetIamPolicy, IdentityRestStub): def __hash__(self): @@ -1587,19 +1772,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -1662,7 +1861,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -1696,7 +1903,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(_BaseIdentityRestTransport._BaseTestIamPermissions, IdentityRestStub): def __hash__(self): @@ -1710,20 +1917,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -1786,7 +2007,16 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = IdentityRestTransport._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. @@ -1820,7 +2050,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(_BaseIdentityRestTransport._BaseListOperations, IdentityRestStub): def __hash__(self): @@ -1834,19 +2064,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -1909,7 +2153,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -1943,7 +2195,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(_BaseIdentityRestTransport._BaseGetOperation, IdentityRestStub): def __hash__(self): @@ -1957,19 +2209,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2032,7 +2298,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -2066,7 +2340,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(_BaseIdentityRestTransport._BaseDeleteOperation, IdentityRestStub): def __hash__(self): @@ -2080,19 +2354,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -2152,7 +2440,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. @@ -2163,7 +2459,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(_BaseIdentityRestTransport._BaseCancelOperation, IdentityRestStub): def __hash__(self): @@ -2177,19 +2473,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -2249,7 +2559,15 @@ def __call__(self, ) # Send the request - response = IdentityRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = IdentityRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/rest_base.py index a273a518fbc3..2740986c9745 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/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 @@ -52,6 +53,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: @@ -72,6 +75,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) @@ -87,7 +93,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 _BaseCreateUser: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/client.py index c45b8ef52e2a..4f1243b92c6b 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._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,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., MessagingTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/messaging/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py index 56895799a573..320b14bf8733 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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.showcase_v1beta1 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 @@ -38,6 +40,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class MessagingTransport(abc.ABC): """Abstract transport class for Messaging.""" @@ -57,6 +72,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 +103,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,21 +143,88 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.create_room: gapic_v1.method.wrap_method( + self.create_room: self._wrap_method( self.create_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/CreateRoom", ), - self.get_room: gapic_v1.method.wrap_method( + self.get_room: self._wrap_method( self.get_room, default_retry=retries.Retry( initial=0.1, @@ -152,18 +238,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/GetRoom", ), - self.update_room: gapic_v1.method.wrap_method( + self.update_room: self._wrap_method( self.update_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/UpdateRoom", ), - self.delete_room: gapic_v1.method.wrap_method( + self.delete_room: self._wrap_method( self.delete_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/DeleteRoom", ), - self.list_rooms: gapic_v1.method.wrap_method( + self.list_rooms: self._wrap_method( self.list_rooms, default_retry=retries.Retry( initial=0.1, @@ -177,13 +266,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/ListRooms", ), - self.create_blurb: gapic_v1.method.wrap_method( + self.create_blurb: self._wrap_method( self.create_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/CreateBlurb", ), - self.get_blurb: gapic_v1.method.wrap_method( + self.get_blurb: self._wrap_method( self.get_blurb, default_retry=retries.Retry( initial=0.1, @@ -197,18 +288,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/GetBlurb", ), - self.update_blurb: gapic_v1.method.wrap_method( + self.update_blurb: self._wrap_method( self.update_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/UpdateBlurb", ), - self.delete_blurb: gapic_v1.method.wrap_method( + self.delete_blurb: self._wrap_method( self.delete_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/DeleteBlurb", ), - self.list_blurbs: gapic_v1.method.wrap_method( + self.list_blurbs: self._wrap_method( self.list_blurbs, default_retry=retries.Retry( initial=0.1, @@ -222,8 +316,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/ListBlurbs", ), - self.search_blurbs: gapic_v1.method.wrap_method( + self.search_blurbs: self._wrap_method( self.search_blurbs, default_retry=retries.Retry( initial=0.1, @@ -237,18 +332,23 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/SearchBlurbs", ), - self.stream_blurbs: gapic_v1.method.wrap_method( + self.stream_blurbs: self._wrap_method( self.stream_blurbs, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/StreamBlurbs", + is_streaming=True, ), - self.send_blurbs: gapic_v1.method.wrap_method( + self.send_blurbs: self._wrap_method( self.send_blurbs, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/SendBlurbs", + is_streaming=True, ), - self.connect: gapic_v1.method.wrap_method( + self.connect: self._wrap_method( self.connect, default_retry=retries.Retry( initial=0.1, @@ -262,51 +362,62 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/Connect", + is_streaming=True, ), - 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_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.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.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.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.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.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.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.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", ), } @@ -534,7 +645,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py index 4d747c43ed51..bcedcc7bf087 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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.showcase_v1beta1._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 @@ -39,6 +41,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import MessagingTransport, 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py index 0011029fc37e..59894199f1c6 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -724,14 +753,18 @@ def connect(self) -> Callable[ return self._stubs['connect'] 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.create_room: self._wrap_method( + self.create_room: self._wrap_async_method( self.create_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/CreateRoom", ), - self.get_room: self._wrap_method( + self.get_room: self._wrap_async_method( self.get_room, default_retry=retries.AsyncRetry( initial=0.1, @@ -745,18 +778,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/GetRoom", ), - self.update_room: self._wrap_method( + self.update_room: self._wrap_async_method( self.update_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/UpdateRoom", ), - self.delete_room: self._wrap_method( + self.delete_room: self._wrap_async_method( self.delete_room, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/DeleteRoom", ), - self.list_rooms: self._wrap_method( + self.list_rooms: self._wrap_async_method( self.list_rooms, default_retry=retries.AsyncRetry( initial=0.1, @@ -770,13 +806,15 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/ListRooms", ), - self.create_blurb: self._wrap_method( + self.create_blurb: self._wrap_async_method( self.create_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/CreateBlurb", ), - self.get_blurb: self._wrap_method( + self.get_blurb: self._wrap_async_method( self.get_blurb, default_retry=retries.AsyncRetry( initial=0.1, @@ -790,18 +828,21 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/GetBlurb", ), - self.update_blurb: self._wrap_method( + self.update_blurb: self._wrap_async_method( self.update_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/UpdateBlurb", ), - self.delete_blurb: self._wrap_method( + self.delete_blurb: self._wrap_async_method( self.delete_blurb, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/DeleteBlurb", ), - self.list_blurbs: self._wrap_method( + self.list_blurbs: self._wrap_async_method( self.list_blurbs, default_retry=retries.AsyncRetry( initial=0.1, @@ -815,8 +856,9 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/ListBlurbs", ), - self.search_blurbs: self._wrap_method( + self.search_blurbs: self._wrap_async_method( self.search_blurbs, default_retry=retries.AsyncRetry( initial=0.1, @@ -830,18 +872,23 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/SearchBlurbs", ), - self.stream_blurbs: self._wrap_method( + self.stream_blurbs: self._wrap_async_method( self.stream_blurbs, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/StreamBlurbs", + is_streaming=True, ), - self.send_blurbs: self._wrap_method( + self.send_blurbs: self._wrap_async_method( self.send_blurbs, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/SendBlurbs", + is_streaming=True, ), - self.connect: self._wrap_method( + self.connect: self._wrap_async_method( self.connect, default_retry=retries.AsyncRetry( initial=0.1, @@ -855,59 +902,65 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.Messaging/Connect", + is_streaming=True, ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/messaging/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/rest.py index c3b269098522..31f9eaeb3bb8 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request import google.protobuf from google.protobuf import json_format @@ -43,6 +44,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseMessagingRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -747,6 +749,7 @@ class MessagingRestStub: _session: AuthorizedSession _host: str _interceptor: MessagingRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class MessagingRestTransport(_BaseMessagingRestTransport): @@ -777,6 +780,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[MessagingRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -820,6 +825,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. @@ -831,7 +839,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) @@ -915,20 +925,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.CreateBlurbRequest, *, @@ -996,7 +1020,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._CreateBlurb._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._CreateBlurb._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. @@ -1008,7 +1041,6 @@ def __call__(self, pb_resp = messaging.Blurb.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_blurb(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_blurb_with_metadata(resp, response_metadata) @@ -1045,20 +1077,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.CreateRoomRequest, *, @@ -1123,7 +1169,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._CreateRoom._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._CreateRoom._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. @@ -1135,7 +1190,6 @@ def __call__(self, pb_resp = messaging.Room.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_room(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_room_with_metadata(resp, response_metadata) @@ -1172,19 +1226,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.DeleteBlurbRequest, *, @@ -1245,7 +1313,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._DeleteBlurb._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._DeleteBlurb._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. @@ -1264,19 +1340,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.DeleteRoomRequest, *, @@ -1337,7 +1427,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._DeleteRoom._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._DeleteRoom._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. @@ -1356,19 +1454,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.GetBlurbRequest, *, @@ -1436,7 +1548,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._GetBlurb._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._GetBlurb._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. @@ -1448,7 +1568,6 @@ def __call__(self, pb_resp = messaging.Blurb.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_blurb(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_blurb_with_metadata(resp, response_metadata) @@ -1485,19 +1604,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.GetRoomRequest, *, @@ -1562,7 +1695,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._GetRoom._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._GetRoom._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. @@ -1574,7 +1715,6 @@ def __call__(self, pb_resp = messaging.Room.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_room(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_room_with_metadata(resp, response_metadata) @@ -1611,19 +1751,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.ListBlurbsRequest, *, @@ -1691,7 +1845,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._ListBlurbs._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._ListBlurbs._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. @@ -1703,7 +1865,6 @@ def __call__(self, pb_resp = messaging.ListBlurbsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_blurbs(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_blurbs_with_metadata(resp, response_metadata) @@ -1740,19 +1901,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.ListRoomsRequest, *, @@ -1820,7 +1995,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._ListRooms._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._ListRooms._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. @@ -1832,7 +2015,6 @@ def __call__(self, pb_resp = messaging.ListRoomsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_rooms(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_rooms_with_metadata(resp, response_metadata) @@ -1869,20 +2051,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.SearchBlurbsRequest, *, @@ -1950,7 +2146,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._SearchBlurbs._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._SearchBlurbs._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. @@ -1960,7 +2165,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_search_blurbs(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_search_blurbs_with_metadata(resp, response_metadata) @@ -2010,21 +2214,35 @@ 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, - stream=True, + body=body, + ) as trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + stream=True, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.StreamBlurbsRequest, *, @@ -2092,7 +2310,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._StreamBlurbs._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._StreamBlurbs._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. @@ -2101,7 +2328,6 @@ def __call__(self, # Return the response resp = rest_streaming.ResponseIterator(response, messaging.StreamBlurbsResponse) - resp = self._interceptor.post_stream_blurbs(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_stream_blurbs_with_metadata(resp, response_metadata) @@ -2133,20 +2359,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.UpdateBlurbRequest, *, @@ -2214,7 +2454,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._UpdateBlurb._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._UpdateBlurb._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. @@ -2226,7 +2475,6 @@ def __call__(self, pb_resp = messaging.Blurb.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_blurb(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_blurb_with_metadata(resp, response_metadata) @@ -2263,20 +2511,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: messaging.UpdateRoomRequest, *, @@ -2341,7 +2603,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._UpdateRoom._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._UpdateRoom._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. @@ -2353,7 +2624,6 @@ def __call__(self, pb_resp = messaging.Room.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_update_room(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_update_room_with_metadata(resp, response_metadata) @@ -2384,7 +2654,7 @@ def connect(self) -> Callable[ messaging.StreamBlurbsResponse]: # 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._Connect(self._session, self._host, self._interceptor) # type: ignore + return self._Connect(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_blurb(self) -> Callable[ @@ -2392,7 +2662,7 @@ def create_blurb(self) -> Callable[ messaging.Blurb]: # 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._CreateBlurb(self._session, self._host, self._interceptor) # type: ignore + return self._CreateBlurb(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_room(self) -> Callable[ @@ -2400,7 +2670,7 @@ def create_room(self) -> Callable[ messaging.Room]: # 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._CreateRoom(self._session, self._host, self._interceptor) # type: ignore + return self._CreateRoom(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_blurb(self) -> Callable[ @@ -2408,7 +2678,7 @@ def delete_blurb(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._DeleteBlurb(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteBlurb(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_room(self) -> Callable[ @@ -2416,7 +2686,7 @@ def delete_room(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._DeleteRoom(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteRoom(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_blurb(self) -> Callable[ @@ -2424,7 +2694,7 @@ def get_blurb(self) -> Callable[ messaging.Blurb]: # 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._GetBlurb(self._session, self._host, self._interceptor) # type: ignore + return self._GetBlurb(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_room(self) -> Callable[ @@ -2432,7 +2702,7 @@ def get_room(self) -> Callable[ messaging.Room]: # 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._GetRoom(self._session, self._host, self._interceptor) # type: ignore + return self._GetRoom(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_blurbs(self) -> Callable[ @@ -2440,7 +2710,7 @@ def list_blurbs(self) -> Callable[ messaging.ListBlurbsResponse]: # 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._ListBlurbs(self._session, self._host, self._interceptor) # type: ignore + return self._ListBlurbs(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_rooms(self) -> Callable[ @@ -2448,7 +2718,7 @@ def list_rooms(self) -> Callable[ messaging.ListRoomsResponse]: # 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._ListRooms(self._session, self._host, self._interceptor) # type: ignore + return self._ListRooms(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def search_blurbs(self) -> Callable[ @@ -2456,7 +2726,7 @@ def search_blurbs(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._SearchBlurbs(self._session, self._host, self._interceptor) # type: ignore + return self._SearchBlurbs(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def send_blurbs(self) -> Callable[ @@ -2464,7 +2734,7 @@ def send_blurbs(self) -> Callable[ messaging.SendBlurbsResponse]: # 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._SendBlurbs(self._session, self._host, self._interceptor) # type: ignore + return self._SendBlurbs(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def stream_blurbs(self) -> Callable[ @@ -2472,7 +2742,7 @@ def stream_blurbs(self) -> Callable[ messaging.StreamBlurbsResponse]: # 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._StreamBlurbs(self._session, self._host, self._interceptor) # type: ignore + return self._StreamBlurbs(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_blurb(self) -> Callable[ @@ -2480,7 +2750,7 @@ def update_blurb(self) -> Callable[ messaging.Blurb]: # 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._UpdateBlurb(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateBlurb(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def update_room(self) -> Callable[ @@ -2488,11 +2758,11 @@ def update_room(self) -> Callable[ messaging.Room]: # 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._UpdateRoom(self._session, self._host, self._interceptor) # type: ignore + return self._UpdateRoom(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseMessagingRestTransport._BaseListLocations, MessagingRestStub): def __hash__(self): @@ -2506,19 +2776,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -2581,7 +2865,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -2615,7 +2907,7 @@ def __call__(self, @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(_BaseMessagingRestTransport._BaseGetLocation, MessagingRestStub): def __hash__(self): @@ -2629,19 +2921,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -2704,7 +3010,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -2738,7 +3052,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(_BaseMessagingRestTransport._BaseSetIamPolicy, MessagingRestStub): def __hash__(self): @@ -2752,20 +3066,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -2828,7 +3156,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._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. @@ -2862,7 +3199,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(_BaseMessagingRestTransport._BaseGetIamPolicy, MessagingRestStub): def __hash__(self): @@ -2876,19 +3213,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -2951,7 +3302,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -2985,7 +3344,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(_BaseMessagingRestTransport._BaseTestIamPermissions, MessagingRestStub): def __hash__(self): @@ -2999,20 +3358,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -3075,7 +3448,16 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = MessagingRestTransport._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. @@ -3109,7 +3491,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(_BaseMessagingRestTransport._BaseListOperations, MessagingRestStub): def __hash__(self): @@ -3123,19 +3505,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -3198,7 +3594,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -3232,7 +3636,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(_BaseMessagingRestTransport._BaseGetOperation, MessagingRestStub): def __hash__(self): @@ -3246,19 +3650,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -3321,7 +3739,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -3355,7 +3781,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(_BaseMessagingRestTransport._BaseDeleteOperation, MessagingRestStub): def __hash__(self): @@ -3369,19 +3795,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -3441,7 +3881,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. @@ -3452,7 +3900,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(_BaseMessagingRestTransport._BaseCancelOperation, MessagingRestStub): def __hash__(self): @@ -3466,19 +3914,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -3538,7 +4000,15 @@ def __call__(self, ) # Send the request - response = MessagingRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = MessagingRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/rest_base.py index 8333cfaf4ea7..fd5e21a2deb9 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/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 @@ -52,6 +53,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: @@ -72,6 +75,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) @@ -87,7 +93,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 _BaseConnect: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py index 503c4d94fd89..b7ab558e7c55 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -467,18 +474,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ResumableUploadServiceTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py index 856b01756ab3..3dc72758979d 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_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.showcase_v1beta1 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 @@ -36,6 +38,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class ResumableUploadServiceTransport(abc.ABC): """Abstract transport class for ResumableUploadService.""" @@ -55,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. @@ -85,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. @@ -122,64 +141,140 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.upload_media: gapic_v1.method.wrap_method( + self.upload_media: self._wrap_method( self.upload_media, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.ResumableUploadService/UploadMedia", ), - 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_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.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.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.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.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.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.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.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", ), } @@ -285,7 +380,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py index e5d0324cf558..76fad1663e15 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/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.showcase_v1beta1._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 @@ -37,6 +39,11 @@ from google.showcase_v1beta1.types import resumable_upload from .base import ResumableUploadServiceTransport, 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 @@ -134,6 +141,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. @@ -184,6 +200,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 @@ -239,6 +261,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: @@ -260,6 +283,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py index 7bde6f9e3205..6a1977280e9b 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_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 @@ -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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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 @@ -181,6 +182,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. @@ -232,6 +236,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 @@ -286,6 +295,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: @@ -307,10 +318,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -351,65 +380,73 @@ def upload_media(self) -> Callable[ return self._stubs['upload_media'] 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.upload_media: self._wrap_method( + self.upload_media: self._wrap_async_method( self.upload_media, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.ResumableUploadService/UploadMedia", ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py index b2d16c22280f..2ee48c0e87e9 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request import google.protobuf from google.protobuf import json_format @@ -41,6 +42,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseResumableUploadServiceRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -325,6 +327,7 @@ class ResumableUploadServiceRestStub: _session: AuthorizedSession _host: str _interceptor: ResumableUploadServiceRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class ResumableUploadServiceRestTransport(_BaseResumableUploadServiceRestTransport): @@ -353,6 +356,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[ResumableUploadServiceRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -396,6 +401,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. @@ -407,7 +415,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) @@ -428,20 +438,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: resumable_upload.UploadMediaRequest, *, @@ -504,7 +528,16 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._UploadMedia._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ResumableUploadServiceRestTransport._UploadMedia._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. @@ -516,7 +549,6 @@ def __call__(self, pb_resp = resumable_upload.UploadMediaResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_upload_media(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_upload_media_with_metadata(resp, response_metadata) @@ -547,11 +579,11 @@ def upload_media(self) -> Callable[ resumable_upload.UploadMediaResponse]: # 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._UploadMedia(self._session, self._host, self._interceptor) # type: ignore + return self._UploadMedia(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseResumableUploadServiceRestTransport._BaseListLocations, ResumableUploadServiceRestStub): def __hash__(self): @@ -565,19 +597,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -640,7 +686,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -674,7 +728,7 @@ def __call__(self, @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(_BaseResumableUploadServiceRestTransport._BaseGetLocation, ResumableUploadServiceRestStub): def __hash__(self): @@ -688,19 +742,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -763,7 +831,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -797,7 +873,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(_BaseResumableUploadServiceRestTransport._BaseSetIamPolicy, ResumableUploadServiceRestStub): def __hash__(self): @@ -811,20 +887,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -887,7 +977,16 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ResumableUploadServiceRestTransport._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. @@ -921,7 +1020,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(_BaseResumableUploadServiceRestTransport._BaseGetIamPolicy, ResumableUploadServiceRestStub): def __hash__(self): @@ -935,19 +1034,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -1010,7 +1123,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -1044,7 +1165,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(_BaseResumableUploadServiceRestTransport._BaseTestIamPermissions, ResumableUploadServiceRestStub): def __hash__(self): @@ -1058,20 +1179,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -1134,7 +1269,16 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = ResumableUploadServiceRestTransport._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. @@ -1168,7 +1312,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(_BaseResumableUploadServiceRestTransport._BaseListOperations, ResumableUploadServiceRestStub): def __hash__(self): @@ -1182,19 +1326,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -1257,7 +1415,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -1291,7 +1457,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(_BaseResumableUploadServiceRestTransport._BaseGetOperation, ResumableUploadServiceRestStub): def __hash__(self): @@ -1305,19 +1471,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -1380,7 +1560,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -1414,7 +1602,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(_BaseResumableUploadServiceRestTransport._BaseDeleteOperation, ResumableUploadServiceRestStub): def __hash__(self): @@ -1428,19 +1616,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -1500,7 +1702,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. @@ -1511,7 +1721,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(_BaseResumableUploadServiceRestTransport._BaseCancelOperation, ResumableUploadServiceRestStub): def __hash__(self): @@ -1525,19 +1735,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -1597,7 +1821,15 @@ def __call__(self, ) # Send the request - response = ResumableUploadServiceRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = ResumableUploadServiceRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py index a02842ea30de..34240857f5b7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_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 google.iam.v1 import iam_policy_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 _BaseUploadMedia: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/client.py index 05e528cae2bb..35a21577be8c 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -513,18 +520,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., SequenceServiceTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py index c3874945257d..945f002269d8 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_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.showcase_v1beta1 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 @@ -38,6 +40,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class SequenceServiceTransport(abc.ABC): """Abstract transport class for SequenceService.""" @@ -57,6 +72,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 +103,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,36 +143,106 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.create_sequence: gapic_v1.method.wrap_method( + self.create_sequence: self._wrap_method( self.create_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/CreateSequence", ), - self.create_streaming_sequence: gapic_v1.method.wrap_method( + self.create_streaming_sequence: self._wrap_method( self.create_streaming_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/CreateStreamingSequence", ), - self.get_sequence_report: gapic_v1.method.wrap_method( + self.get_sequence_report: self._wrap_method( self.get_sequence_report, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/GetSequenceReport", ), - self.get_streaming_sequence_report: gapic_v1.method.wrap_method( + self.get_streaming_sequence_report: self._wrap_method( self.get_streaming_sequence_report, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/GetStreamingSequenceReport", ), - self.attempt_sequence: gapic_v1.method.wrap_method( + self.attempt_sequence: self._wrap_method( self.attempt_sequence, default_retry=retries.Retry( initial=0.1, @@ -167,56 +256,68 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/AttemptSequence", ), - self.attempt_streaming_sequence: gapic_v1.method.wrap_method( + self.attempt_streaming_sequence: self._wrap_method( self.attempt_streaming_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/AttemptStreamingSequence", + is_streaming=True, ), - 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_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.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.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.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.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.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.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.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", ), } @@ -367,7 +468,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py index 3b0484988bcb..a100a7d0454a 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/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.showcase_v1beta1._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 @@ -39,6 +41,11 @@ import google.protobuf.empty_pb2 as empty_pb2 # type: ignore from .base import SequenceServiceTransport, 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 @@ -137,6 +144,15 @@ def __init__(self, *, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, always_use_jwt_access: Optional[bool] = False, api_audience: Optional[str] = None, + interceptors: Optional[ + Sequence[ + Union[ + "ClientInterceptor", + Callable[[grpc.Channel], grpc.Channel], + ] + ] + ] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, ) -> None: """Instantiate the transport. @@ -187,6 +203,12 @@ def __init__(self, *, to the service that will be set when using certain 3rd party authentication flows. Audience is typically a resource identifier. If not set, the host value will be used as a default. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. + client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]): + Custom options for the client, containing options such as + custom OpenTelemetry tracer providers. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -242,6 +264,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: @@ -263,6 +286,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py index bc557fde03c0..75cf4bd9be7b 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_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 @@ -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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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 @@ -184,6 +185,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. @@ -235,6 +239,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 @@ -289,6 +298,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: @@ -310,10 +321,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -494,29 +523,36 @@ def attempt_streaming_sequence(self) -> Callable[ return self._stubs['attempt_streaming_sequence'] 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.create_sequence: self._wrap_method( + self.create_sequence: self._wrap_async_method( self.create_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/CreateSequence", ), - self.create_streaming_sequence: self._wrap_method( + self.create_streaming_sequence: self._wrap_async_method( self.create_streaming_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/CreateStreamingSequence", ), - self.get_sequence_report: self._wrap_method( + self.get_sequence_report: self._wrap_async_method( self.get_sequence_report, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/GetSequenceReport", ), - self.get_streaming_sequence_report: self._wrap_method( + self.get_streaming_sequence_report: self._wrap_async_method( self.get_streaming_sequence_report, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/GetStreamingSequenceReport", ), - self.attempt_sequence: self._wrap_method( + self.attempt_sequence: self._wrap_async_method( self.attempt_sequence, default_retry=retries.AsyncRetry( initial=0.1, @@ -530,64 +566,71 @@ def _prep_wrapped_messages(self, client_info): ), default_timeout=10.0, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/AttemptSequence", ), - self.attempt_streaming_sequence: self._wrap_method( + self.attempt_streaming_sequence: self._wrap_async_method( self.attempt_streaming_sequence, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.SequenceService/AttemptStreamingSequence", + is_streaming=True, ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest.py index f2dd71e1e1bd..49b8c9349024 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request import google.protobuf from google.protobuf import json_format @@ -43,6 +44,7 @@ from google.longrunning import operations_pb2 # type: ignore +from google.api_core import client_options as client_options_lib from .rest_base import _BaseSequenceServiceRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -515,6 +517,7 @@ class SequenceServiceRestStub: _session: AuthorizedSession _host: str _interceptor: SequenceServiceRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class SequenceServiceRestTransport(_BaseSequenceServiceRestTransport): @@ -544,6 +547,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[SequenceServiceRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -587,6 +592,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. @@ -598,7 +606,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) @@ -619,20 +629,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: sequence.AttemptSequenceRequest, *, @@ -692,7 +716,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._AttemptSequence._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._AttemptSequence._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. @@ -711,21 +744,35 @@ 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, - stream=True, + body=body, + ) as trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, + stream=True, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: sequence.AttemptStreamingSequenceRequest, *, @@ -792,7 +839,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._AttemptStreamingSequence._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._AttemptStreamingSequence._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. @@ -801,7 +857,6 @@ def __call__(self, # Return the response resp = rest_streaming.ResponseIterator(response, sequence.AttemptStreamingSequenceResponse) - resp = self._interceptor.post_attempt_streaming_sequence(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_attempt_streaming_sequence_with_metadata(resp, response_metadata) @@ -833,20 +888,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: gs_sequence.CreateSequenceRequest, *, @@ -913,7 +982,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._CreateSequence._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._CreateSequence._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. @@ -925,7 +1003,6 @@ def __call__(self, pb_resp = gs_sequence.Sequence.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_sequence(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_sequence_with_metadata(resp, response_metadata) @@ -962,20 +1039,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: sequence.CreateStreamingSequenceRequest, *, @@ -1043,7 +1134,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._CreateStreamingSequence._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._CreateStreamingSequence._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. @@ -1055,7 +1155,6 @@ def __call__(self, pb_resp = sequence.StreamingSequence.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_streaming_sequence(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_streaming_sequence_with_metadata(resp, response_metadata) @@ -1092,19 +1191,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: sequence.GetSequenceReportRequest, *, @@ -1169,7 +1282,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._GetSequenceReport._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._GetSequenceReport._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. @@ -1181,7 +1302,6 @@ def __call__(self, pb_resp = sequence.SequenceReport.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_sequence_report(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_sequence_report_with_metadata(resp, response_metadata) @@ -1218,19 +1338,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: sequence.GetStreamingSequenceReportRequest, *, @@ -1296,7 +1430,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._GetStreamingSequenceReport._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._GetStreamingSequenceReport._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. @@ -1308,7 +1450,6 @@ def __call__(self, pb_resp = sequence.StreamingSequenceReport.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_streaming_sequence_report(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_streaming_sequence_report_with_metadata(resp, response_metadata) @@ -1339,7 +1480,7 @@ def attempt_sequence(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._AttemptSequence(self._session, self._host, self._interceptor) # type: ignore + return self._AttemptSequence(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def attempt_streaming_sequence(self) -> Callable[ @@ -1347,7 +1488,7 @@ def attempt_streaming_sequence(self) -> Callable[ sequence.AttemptStreamingSequenceResponse]: # 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._AttemptStreamingSequence(self._session, self._host, self._interceptor) # type: ignore + return self._AttemptStreamingSequence(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_sequence(self) -> Callable[ @@ -1355,7 +1496,7 @@ def create_sequence(self) -> Callable[ gs_sequence.Sequence]: # 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._CreateSequence(self._session, self._host, self._interceptor) # type: ignore + return self._CreateSequence(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def create_streaming_sequence(self) -> Callable[ @@ -1363,7 +1504,7 @@ def create_streaming_sequence(self) -> Callable[ sequence.StreamingSequence]: # 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._CreateStreamingSequence(self._session, self._host, self._interceptor) # type: ignore + return self._CreateStreamingSequence(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_sequence_report(self) -> Callable[ @@ -1371,7 +1512,7 @@ def get_sequence_report(self) -> Callable[ sequence.SequenceReport]: # 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._GetSequenceReport(self._session, self._host, self._interceptor) # type: ignore + return self._GetSequenceReport(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_streaming_sequence_report(self) -> Callable[ @@ -1379,11 +1520,11 @@ def get_streaming_sequence_report(self) -> Callable[ sequence.StreamingSequenceReport]: # 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._GetStreamingSequenceReport(self._session, self._host, self._interceptor) # type: ignore + return self._GetStreamingSequenceReport(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseSequenceServiceRestTransport._BaseListLocations, SequenceServiceRestStub): def __hash__(self): @@ -1397,19 +1538,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1472,7 +1627,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -1506,7 +1669,7 @@ def __call__(self, @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(_BaseSequenceServiceRestTransport._BaseGetLocation, SequenceServiceRestStub): def __hash__(self): @@ -1520,19 +1683,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1595,7 +1772,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -1629,7 +1814,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(_BaseSequenceServiceRestTransport._BaseSetIamPolicy, SequenceServiceRestStub): def __hash__(self): @@ -1643,20 +1828,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -1719,7 +1918,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._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. @@ -1753,7 +1961,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(_BaseSequenceServiceRestTransport._BaseGetIamPolicy, SequenceServiceRestStub): def __hash__(self): @@ -1767,19 +1975,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -1842,7 +2064,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -1876,7 +2106,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(_BaseSequenceServiceRestTransport._BaseTestIamPermissions, SequenceServiceRestStub): def __hash__(self): @@ -1890,20 +2120,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -1966,7 +2210,16 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = SequenceServiceRestTransport._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. @@ -2000,7 +2253,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(_BaseSequenceServiceRestTransport._BaseListOperations, SequenceServiceRestStub): def __hash__(self): @@ -2014,19 +2267,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2089,7 +2356,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -2123,7 +2398,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(_BaseSequenceServiceRestTransport._BaseGetOperation, SequenceServiceRestStub): def __hash__(self): @@ -2137,19 +2412,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2212,7 +2501,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -2246,7 +2543,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(_BaseSequenceServiceRestTransport._BaseDeleteOperation, SequenceServiceRestStub): def __hash__(self): @@ -2260,19 +2557,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -2332,7 +2643,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. @@ -2343,7 +2662,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(_BaseSequenceServiceRestTransport._BaseCancelOperation, SequenceServiceRestStub): def __hash__(self): @@ -2357,19 +2676,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -2429,7 +2762,15 @@ def __call__(self, ) # Send the request - response = SequenceServiceRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = SequenceServiceRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest_base.py index 837e825aa247..eaf7eadf3eff 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_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 google.iam.v1 import iam_policy_pb2 # type: ignore @@ -53,6 +54,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: @@ -73,6 +76,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) @@ -88,7 +94,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 _BaseAttemptSequence: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/client.py index 8e71a30e7440..ded334b1889c 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, +) from google.showcase_v1beta1._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,32 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., TestingTransport], 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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/showcase/google/showcase_v1beta1/services/testing/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py index 29e6392c4ed4..c929acea95ed 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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.showcase_v1beta1 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class TestingTransport(abc.ABC): """Abstract transport class for Testing.""" @@ -56,6 +71,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. @@ -86,6 +102,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. @@ -123,99 +142,182 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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.create_session: gapic_v1.method.wrap_method( + self.create_session: self._wrap_method( self.create_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/CreateSession", ), - self.get_session: gapic_v1.method.wrap_method( + self.get_session: self._wrap_method( self.get_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/GetSession", ), - self.list_sessions: gapic_v1.method.wrap_method( + self.list_sessions: self._wrap_method( self.list_sessions, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ListSessions", ), - self.delete_session: gapic_v1.method.wrap_method( + self.delete_session: self._wrap_method( self.delete_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/DeleteSession", ), - self.report_session: gapic_v1.method.wrap_method( + self.report_session: self._wrap_method( self.report_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ReportSession", ), - self.list_tests: gapic_v1.method.wrap_method( + self.list_tests: self._wrap_method( self.list_tests, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ListTests", ), - self.delete_test: gapic_v1.method.wrap_method( + self.delete_test: self._wrap_method( self.delete_test, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/DeleteTest", ), - self.verify_test: gapic_v1.method.wrap_method( + self.verify_test: self._wrap_method( self.verify_test, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/VerifyTest", ), - 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_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.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.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.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.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.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.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.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", ), } @@ -384,7 +486,7 @@ def list_locations(self, @property def kind(self) -> str: - raise NotImplementedError() + return "" __all__ = ( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py index 7d28f07c441e..e048ece258da 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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.showcase_v1beta1._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 TestingTransport, 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 @@ -140,6 +147,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. @@ -190,6 +206,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 @@ -245,6 +267,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: @@ -266,6 +289,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py index 5a66af994eb8..14e5bd20e6a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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.showcase_v1beta1._compat import _observability, apply_channel_interceptors 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 @@ -187,6 +188,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. @@ -238,6 +242,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 @@ -292,6 +301,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: @@ -313,10 +324,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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) @@ -555,100 +584,115 @@ def verify_test(self) -> Callable[ return self._stubs['verify_test'] 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.create_session: self._wrap_method( + self.create_session: self._wrap_async_method( self.create_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/CreateSession", ), - self.get_session: self._wrap_method( + self.get_session: self._wrap_async_method( self.get_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/GetSession", ), - self.list_sessions: self._wrap_method( + self.list_sessions: self._wrap_async_method( self.list_sessions, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ListSessions", ), - self.delete_session: self._wrap_method( + self.delete_session: self._wrap_async_method( self.delete_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/DeleteSession", ), - self.report_session: self._wrap_method( + self.report_session: self._wrap_async_method( self.report_session, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ReportSession", ), - self.list_tests: self._wrap_method( + self.list_tests: self._wrap_async_method( self.list_tests, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/ListTests", ), - self.delete_test: self._wrap_method( + self.delete_test: self._wrap_async_method( self.delete_test, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/DeleteTest", ), - self.verify_test: self._wrap_method( + self.verify_test: self._wrap_async_method( self.verify_test, default_timeout=None, client_info=client_info, + method_name="google.showcase.v1beta1.Testing/VerifyTest", ), - 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_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.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.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.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.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.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.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.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", ), } - 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/showcase/google/showcase_v1beta1/services/testing/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/rest.py index 78d25378f0fd..05277bfd17ad 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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.showcase_v1beta1._compat import transcode_request +from google.showcase_v1beta1._compat import transcode_request, trace_http_request 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 _BaseTestingRestTransport from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO @@ -570,6 +572,7 @@ class TestingRestStub: _session: AuthorizedSession _host: str _interceptor: TestingRestInterceptor + _client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None class TestingRestTransport(_BaseTestingRestTransport): @@ -603,6 +606,8 @@ def __init__(self, *, url_scheme: str = 'https', interceptor: Optional[TestingRestInterceptor] = None, api_audience: Optional[str] = None, + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None, + **kwargs, ) -> None: """Instantiate the transport. @@ -646,6 +651,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. @@ -657,7 +665,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) @@ -678,20 +688,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.CreateSessionRequest, *, @@ -762,7 +786,16 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._CreateSession._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = TestingRestTransport._CreateSession._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. @@ -774,7 +807,6 @@ def __call__(self, pb_resp = testing.Session.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_create_session(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_create_session_with_metadata(resp, response_metadata) @@ -811,19 +843,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.DeleteSessionRequest, *, @@ -882,7 +928,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._DeleteSession._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._DeleteSession._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. @@ -901,19 +955,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.DeleteTestRequest, *, @@ -972,7 +1040,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._DeleteTest._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._DeleteTest._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. @@ -991,19 +1067,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.GetSessionRequest, *, @@ -1074,7 +1164,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._GetSession._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._GetSession._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. @@ -1086,7 +1184,6 @@ def __call__(self, pb_resp = testing.Session.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_get_session(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_get_session_with_metadata(resp, response_metadata) @@ -1123,19 +1220,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.ListSessionsRequest, *, @@ -1199,7 +1310,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._ListSessions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._ListSessions._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. @@ -1211,7 +1330,6 @@ def __call__(self, pb_resp = testing.ListSessionsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_sessions(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_sessions_with_metadata(resp, response_metadata) @@ -1248,19 +1366,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.ListTestsRequest, *, @@ -1325,7 +1457,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._ListTests._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._ListTests._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. @@ -1337,7 +1477,6 @@ def __call__(self, pb_resp = testing.ListTestsResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_list_tests(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_list_tests_with_metadata(resp, response_metadata) @@ -1374,19 +1513,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.ReportSessionRequest, *, @@ -1452,7 +1605,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._ReportSession._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._ReportSession._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. @@ -1464,7 +1625,6 @@ def __call__(self, pb_resp = testing.ReportSessionResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_report_session(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_report_session_with_metadata(resp, response_metadata) @@ -1501,19 +1661,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: testing.VerifyTestRequest, *, @@ -1576,7 +1750,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._VerifyTest._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._VerifyTest._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. @@ -1588,7 +1770,6 @@ def __call__(self, pb_resp = testing.VerifyTestResponse.pb(resp) json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - resp = self._interceptor.post_verify_test(resp) response_metadata = [(k, str(v)) for k, v in response.headers.items()] resp, _ = self._interceptor.post_verify_test_with_metadata(resp, response_metadata) @@ -1619,7 +1800,7 @@ def create_session(self) -> Callable[ testing.Session]: # 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._CreateSession(self._session, self._host, self._interceptor) # type: ignore + return self._CreateSession(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_session(self) -> Callable[ @@ -1627,7 +1808,7 @@ def delete_session(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._DeleteSession(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteSession(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def delete_test(self) -> Callable[ @@ -1635,7 +1816,7 @@ def delete_test(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._DeleteTest(self._session, self._host, self._interceptor) # type: ignore + return self._DeleteTest(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def get_session(self) -> Callable[ @@ -1643,7 +1824,7 @@ def get_session(self) -> Callable[ testing.Session]: # 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._GetSession(self._session, self._host, self._interceptor) # type: ignore + return self._GetSession(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_sessions(self) -> Callable[ @@ -1651,7 +1832,7 @@ def list_sessions(self) -> Callable[ testing.ListSessionsResponse]: # 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._ListSessions(self._session, self._host, self._interceptor) # type: ignore + return self._ListSessions(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def list_tests(self) -> Callable[ @@ -1659,7 +1840,7 @@ def list_tests(self) -> Callable[ testing.ListTestsResponse]: # 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._ListTests(self._session, self._host, self._interceptor) # type: ignore + return self._ListTests(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def report_session(self) -> Callable[ @@ -1667,7 +1848,7 @@ def report_session(self) -> Callable[ testing.ReportSessionResponse]: # 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._ReportSession(self._session, self._host, self._interceptor) # type: ignore + return self._ReportSession(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @property def verify_test(self) -> Callable[ @@ -1675,11 +1856,11 @@ def verify_test(self) -> Callable[ testing.VerifyTestResponse]: # 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._VerifyTest(self._session, self._host, self._interceptor) # type: ignore + return self._VerifyTest(self._session, self._host, self._interceptor, getattr(self, "_client_options", None)) # type: ignore @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(_BaseTestingRestTransport._BaseListLocations, TestingRestStub): def __hash__(self): @@ -1693,19 +1874,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.ListLocationsRequest, *, @@ -1768,7 +1963,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._ListLocations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -1802,7 +2005,7 @@ def __call__(self, @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(_BaseTestingRestTransport._BaseGetLocation, TestingRestStub): def __hash__(self): @@ -1816,19 +2019,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: locations_pb2.GetLocationRequest, *, @@ -1891,7 +2108,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._GetLocation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -1925,7 +2150,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(_BaseTestingRestTransport._BaseSetIamPolicy, TestingRestStub): def __hash__(self): @@ -1939,20 +2164,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.SetIamPolicyRequest, *, @@ -2015,7 +2254,16 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._SetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = TestingRestTransport._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. @@ -2049,7 +2297,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(_BaseTestingRestTransport._BaseGetIamPolicy, TestingRestStub): def __hash__(self): @@ -2063,19 +2311,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.GetIamPolicyRequest, *, @@ -2138,7 +2400,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._GetIamPolicy._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -2172,7 +2442,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(_BaseTestingRestTransport._BaseTestIamPermissions, TestingRestStub): def __hash__(self): @@ -2186,20 +2456,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: iam_policy_pb2.TestIamPermissionsRequest, *, @@ -2262,7 +2546,16 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._TestIamPermissions._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) + response = TestingRestTransport._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. @@ -2296,7 +2589,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(_BaseTestingRestTransport._BaseListOperations, TestingRestStub): def __hash__(self): @@ -2310,19 +2603,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.ListOperationsRequest, *, @@ -2385,7 +2692,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._ListOperations._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -2419,7 +2734,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(_BaseTestingRestTransport._BaseGetOperation, TestingRestStub): def __hash__(self): @@ -2433,19 +2748,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.GetOperationRequest, *, @@ -2508,7 +2837,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._GetOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -2542,7 +2879,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(_BaseTestingRestTransport._BaseDeleteOperation, TestingRestStub): def __hash__(self): @@ -2556,19 +2893,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.DeleteOperationRequest, *, @@ -2628,7 +2979,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._DeleteOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. @@ -2639,7 +2998,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(_BaseTestingRestTransport._BaseCancelOperation, TestingRestStub): def __hash__(self): @@ -2653,19 +3012,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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(response) + return response def __call__(self, request: operations_pb2.CancelOperationRequest, *, @@ -2725,7 +3098,15 @@ def __call__(self, ) # Send the request - response = TestingRestTransport._CancelOperation._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request) + response = TestingRestTransport._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. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/rest_base.py index e3b29c7866e4..f5592b41873f 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/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 @@ -52,6 +53,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: @@ -72,6 +75,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) @@ -87,7 +93,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 _BaseCreateSession: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compat.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compat.py index 70cedabe848e..7a34bc480618 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compat.py @@ -25,7 +25,16 @@ import google.auth.transport.mtls from google.showcase_v1beta1._compat import transcode_request -from google.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables +from google.showcase_v1beta1._compat import ( + get_universe_domain, + get_api_endpoint, + get_default_mtls_endpoint, + should_use_client_cert, + read_environment_variables, + _observability, + trace_http_request, + apply_channel_interceptors, +) from google.showcase_v1beta1._compat import setup_request_id from google.auth.exceptions import MutualTLSChannelError @@ -528,3 +537,65 @@ 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 trace_ctx: + trace_ctx.record_response(None) + + +def test_observability_compat_present(monkeypatch): + import importlib + import sys + import google.api_core + from google.showcase_v1beta1 import _compat + + # Simulate an environment where google.api_core._observability is available + mock_obs = mock.MagicMock() + mock_obs.trace_http_request = 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 + finally: + monkeypatch.undo() + importlib.reload(_compat) + + +def test_observability_compat_fallback(monkeypatch): + import importlib + import sys + import google.api_core + from google.showcase_v1beta1 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compliance.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compliance.py index 85678a151788..b284a60dcca0 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compliance.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_compliance.py @@ -59,6 +59,7 @@ from google.showcase_v1beta1.services.compliance import ComplianceAsyncClient from google.showcase_v1beta1.services.compliance import ComplianceClient from google.showcase_v1beta1.services.compliance import transports +from google.showcase_v1beta1.services.compliance.client import _observability from google.showcase_v1beta1.types import compliance import google.auth @@ -755,6 +756,192 @@ def test_compliance_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_compliance_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.ComplianceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ComplianceClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_compliance_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ComplianceGrpcTransport, + "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.ComplianceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_compliance_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.ComplianceGrpcTransport, + "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.ComplianceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_compliance_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.ComplianceGrpcTransport( + 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_compliance_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.ComplianceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.ComplianceGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_compliance_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.ComplianceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.ComplianceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_compliance_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.ComplianceGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.ComplianceGrpcAsyncIOTransport( + 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", [ (ComplianceClient, transports.ComplianceGrpcTransport, "grpc", grpc_helpers), (ComplianceAsyncClient, transports.ComplianceGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -5491,13 +5678,7 @@ def test_compliance_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_compliance_base_transport_with_credentials_file(): @@ -5526,6 +5707,129 @@ def test_compliance_base_transport_with_adc(): adc.assert_called_once() +def test_compliance_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.showcase_v1beta1.services.compliance.transports.ComplianceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ComplianceTransport(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.showcase_v1beta1.services.compliance.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.showcase_v1beta1.services.compliance.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.showcase_v1beta1.services.compliance.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_compliance_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.showcase_v1beta1.services.compliance.transports.ComplianceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ComplianceTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.compliance.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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.compliance.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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.compliance.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_compliance_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/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py index 31dce23eb5e1..613c91ba3de2 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py @@ -63,6 +63,7 @@ from google.showcase_v1beta1.services.echo import EchoClient from google.showcase_v1beta1.services.echo import pagers from google.showcase_v1beta1.services.echo import transports +from google.showcase_v1beta1.services.echo.client import _observability from google.showcase_v1beta1.types import echo as gs_echo import google.api_core.operation_async as operation_async # type: ignore import google.auth @@ -1057,6 +1058,192 @@ def test_echo_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_echo_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.EchoGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EchoClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_echo_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.EchoGrpcTransport, + "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.EchoGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_echo_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.EchoGrpcTransport, + "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.EchoGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_echo_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.EchoGrpcTransport( + 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_echo_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.EchoGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.EchoGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_echo_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.EchoGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.EchoGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_echo_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.EchoGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.EchoGrpcAsyncIOTransport( + 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", [ (EchoClient, transports.EchoGrpcTransport, "grpc", grpc_helpers), (EchoAsyncClient, transports.EchoGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -7417,13 +7604,7 @@ def test_echo_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_echo_base_transport_with_credentials_file(): @@ -7452,6 +7633,129 @@ def test_echo_base_transport_with_adc(): adc.assert_called_once() +def test_echo_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.showcase_v1beta1.services.echo.transports.EchoTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.EchoTransport(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.showcase_v1beta1.services.echo.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.showcase_v1beta1.services.echo.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.showcase_v1beta1.services.echo.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_echo_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.showcase_v1beta1.services.echo.transports.EchoTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.EchoTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.echo.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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.echo.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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.echo.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_echo_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/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py index 86a12873b632..02cb4a83611b 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py @@ -60,6 +60,7 @@ from google.showcase_v1beta1.services.identity import IdentityClient from google.showcase_v1beta1.services.identity import pagers from google.showcase_v1beta1.services.identity import transports +from google.showcase_v1beta1.services.identity.client import _observability from google.showcase_v1beta1.types import identity import google.auth import google.protobuf.field_mask_pb2 as field_mask_pb2 # type: ignore @@ -758,6 +759,192 @@ def test_identity_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_identity_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.IdentityGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IdentityClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_identity_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.IdentityGrpcTransport, + "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.IdentityGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_identity_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.IdentityGrpcTransport, + "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.IdentityGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_identity_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.IdentityGrpcTransport( + 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_identity_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.IdentityGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.IdentityGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_identity_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.IdentityGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.IdentityGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_identity_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.IdentityGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.IdentityGrpcAsyncIOTransport( + 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", [ (IdentityClient, transports.IdentityGrpcTransport, "grpc", grpc_helpers), (IdentityAsyncClient, transports.IdentityGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -4560,13 +4747,7 @@ def test_identity_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_identity_base_transport_with_credentials_file(): @@ -4595,6 +4776,129 @@ def test_identity_base_transport_with_adc(): adc.assert_called_once() +def test_identity_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.showcase_v1beta1.services.identity.transports.IdentityTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.IdentityTransport(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.showcase_v1beta1.services.identity.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.showcase_v1beta1.services.identity.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.showcase_v1beta1.services.identity.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_identity_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.showcase_v1beta1.services.identity.transports.IdentityTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.IdentityTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.identity.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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.identity.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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.identity.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_identity_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/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py index e3a623b5a351..f44dd5bcc0cc 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py @@ -63,6 +63,7 @@ from google.showcase_v1beta1.services.messaging import MessagingClient from google.showcase_v1beta1.services.messaging import pagers from google.showcase_v1beta1.services.messaging import transports +from google.showcase_v1beta1.services.messaging.client import _observability from google.showcase_v1beta1.types import messaging import google.api_core.operation_async as operation_async # type: ignore import google.auth @@ -762,6 +763,192 @@ def test_messaging_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_messaging_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.MessagingGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MessagingClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_messaging_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.MessagingGrpcTransport, + "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.MessagingGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_messaging_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.MessagingGrpcTransport, + "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.MessagingGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_messaging_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.MessagingGrpcTransport( + 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_messaging_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.MessagingGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.MessagingGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_messaging_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.MessagingGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.MessagingGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_messaging_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.MessagingGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.MessagingGrpcAsyncIOTransport( + 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", [ (MessagingClient, transports.MessagingGrpcTransport, "grpc", grpc_helpers), (MessagingAsyncClient, transports.MessagingGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -9355,13 +9542,7 @@ def test_messaging_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_messaging_base_transport_with_credentials_file(): @@ -9390,6 +9571,129 @@ def test_messaging_base_transport_with_adc(): adc.assert_called_once() +def test_messaging_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.showcase_v1beta1.services.messaging.transports.MessagingTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.MessagingTransport(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.showcase_v1beta1.services.messaging.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.showcase_v1beta1.services.messaging.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.showcase_v1beta1.services.messaging.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_messaging_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.showcase_v1beta1.services.messaging.transports.MessagingTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.MessagingTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.messaging.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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.messaging.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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.messaging.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_messaging_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/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py index dac0fc5053f7..191583c9f644 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py @@ -59,6 +59,7 @@ from google.showcase_v1beta1.services.resumable_upload_service import ResumableUploadServiceAsyncClient from google.showcase_v1beta1.services.resumable_upload_service import ResumableUploadServiceClient from google.showcase_v1beta1.services.resumable_upload_service import transports +from google.showcase_v1beta1.services.resumable_upload_service.client import _observability from google.showcase_v1beta1.types import resumable_upload import google.auth @@ -755,6 +756,192 @@ def test_resumable_upload_service_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_resumable_upload_service_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.ResumableUploadServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ResumableUploadServiceClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_resumable_upload_service_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ResumableUploadServiceGrpcTransport, + "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.ResumableUploadServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_resumable_upload_service_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.ResumableUploadServiceGrpcTransport, + "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.ResumableUploadServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_resumable_upload_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.ResumableUploadServiceGrpcTransport( + 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_resumable_upload_service_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.ResumableUploadServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_resumable_upload_service_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.ResumableUploadServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_resumable_upload_service_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.ResumableUploadServiceGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + 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", [ (ResumableUploadServiceClient, transports.ResumableUploadServiceGrpcTransport, "grpc", grpc_helpers), (ResumableUploadServiceAsyncClient, transports.ResumableUploadServiceGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -1847,13 +2034,7 @@ def test_resumable_upload_service_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_resumable_upload_service_base_transport_with_credentials_file(): @@ -1882,6 +2063,129 @@ def test_resumable_upload_service_base_transport_with_adc(): adc.assert_called_once() +def test_resumable_upload_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.showcase_v1beta1.services.resumable_upload_service.transports.ResumableUploadServiceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ResumableUploadServiceTransport(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.showcase_v1beta1.services.resumable_upload_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.showcase_v1beta1.services.resumable_upload_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.showcase_v1beta1.services.resumable_upload_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_resumable_upload_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.showcase_v1beta1.services.resumable_upload_service.transports.ResumableUploadServiceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.ResumableUploadServiceTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.resumable_upload_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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.resumable_upload_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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.resumable_upload_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_resumable_upload_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/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py index 9bf3ccce431d..f27787c0e484 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py @@ -59,6 +59,7 @@ from google.showcase_v1beta1.services.sequence_service import SequenceServiceAsyncClient from google.showcase_v1beta1.services.sequence_service import SequenceServiceClient from google.showcase_v1beta1.services.sequence_service import transports +from google.showcase_v1beta1.services.sequence_service.client import _observability from google.showcase_v1beta1.types import sequence from google.showcase_v1beta1.types import sequence as gs_sequence import google.auth @@ -759,6 +760,192 @@ def test_sequence_service_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_sequence_service_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.SequenceServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = SequenceServiceClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_sequence_service_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.SequenceServiceGrpcTransport, + "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.SequenceServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_sequence_service_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.SequenceServiceGrpcTransport, + "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.SequenceServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_sequence_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.SequenceServiceGrpcTransport( + 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_sequence_service_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.SequenceServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.SequenceServiceGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_sequence_service_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.SequenceServiceGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.SequenceServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_sequence_service_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.SequenceServiceGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.SequenceServiceGrpcAsyncIOTransport( + 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", [ (SequenceServiceClient, transports.SequenceServiceGrpcTransport, "grpc", grpc_helpers), (SequenceServiceAsyncClient, transports.SequenceServiceGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -5186,13 +5373,7 @@ def test_sequence_service_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_sequence_service_base_transport_with_credentials_file(): @@ -5221,6 +5402,129 @@ def test_sequence_service_base_transport_with_adc(): adc.assert_called_once() +def test_sequence_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.showcase_v1beta1.services.sequence_service.transports.SequenceServiceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.SequenceServiceTransport(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.showcase_v1beta1.services.sequence_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.showcase_v1beta1.services.sequence_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.showcase_v1beta1.services.sequence_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_sequence_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.showcase_v1beta1.services.sequence_service.transports.SequenceServiceTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.SequenceServiceTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.sequence_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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.sequence_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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.sequence_service.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_sequence_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/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py index 98f0c2573ebd..23de230a31d6 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py @@ -60,6 +60,7 @@ from google.showcase_v1beta1.services.testing import TestingClient from google.showcase_v1beta1.services.testing import pagers from google.showcase_v1beta1.services.testing import transports +from google.showcase_v1beta1.services.testing.client import _observability from google.showcase_v1beta1.types import testing import google.auth @@ -756,6 +757,192 @@ def test_testing_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_testing_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.TestingGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = TestingClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + assert not called_kwargs.get("client_options") + + +def test_testing_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.TestingGrpcTransport, + "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.TestingGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_testing_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + mock.patch.object( + transports.TestingGrpcTransport, + "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.TestingGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_testing_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.TestingGrpcTransport( + 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_testing_grpc_asyncio_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with mock.patch.object( + transports.TestingGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel: + transport = transports.TestingGrpcAsyncIOTransport( + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_testing_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + mock.patch.object( + transports.TestingGrpcAsyncIOTransport, + "create_channel", + return_value=mock_channel, + ) as mock_create_channel, + ): + options = client_options.ClientOptions() + transport = transports.TestingGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + + mock_get_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_testing_grpc_asyncio_transport_custom_channel(): + mock_custom_channel = mock.Mock(spec=aio.Channel) + + with mock.patch.object( + transports.TestingGrpcAsyncIOTransport, + "create_channel", + ) as mock_create_channel: + transport = transports.TestingGrpcAsyncIOTransport( + 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", [ (TestingClient, transports.TestingGrpcTransport, "grpc", grpc_helpers), (TestingAsyncClient, transports.TestingGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), @@ -5384,13 +5571,7 @@ def test_testing_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_testing_base_transport_with_credentials_file(): @@ -5419,6 +5600,129 @@ def test_testing_base_transport_with_adc(): adc.assert_called_once() +def test_testing_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.showcase_v1beta1.services.testing.transports.TestingTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.TestingTransport(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.showcase_v1beta1.services.testing.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.showcase_v1beta1.services.testing.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.showcase_v1beta1.services.testing.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_testing_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.showcase_v1beta1.services.testing.transports.TestingTransport._prep_wrapped_messages') as prep: + adc.return_value = (ga_credentials.AnonymousCredentials(), None) + transport = transports.TestingTransport(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" + + # Generation 1: Modern google-api-core with tracing support + with mock.patch( + "google.showcase_v1beta1.services.testing.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" + + # Generation 1 with default/empty kind on base transport + with mock.patch( + "google.showcase_v1beta1.services.testing.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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.showcase_v1beta1.services.testing.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + def test_testing_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(google.auth, 'default', autospec=True) as adc: diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py index d7096741a7f9..6687b6cdc02a 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 @@ -20,18 +20,121 @@ import uuid import google.protobuf.message -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, 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. + class _FallbackTraceContext: + def __enter__(self) -> "_FallbackTraceContext": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def record_response(self, response: Any) -> None: + pass + + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + pass + + record_http_error = record_error + + def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: + return _FallbackTraceContext() + +try: + from google.api_core import grpc_helpers_async +except ImportError: # pragma: NO COVER + grpc_helpers_async = None # type: ignore[assignment] + + +def _fallback_apply_interceptors( + channel: Any, interceptors: Optional[Sequence[Any]] = None +) -> Any: # pragma: NO COVER + """Fallback helper to attach interceptors to an existing async gRPC channel. + + Synchronous gRPC channels allow post-creation wrapping via + `grpc.intercept_channel(channel, *interceptors)`. However, asynchronous + channels (`grpc.aio.Channel`) in grpcio are immutable post-creation and + normally require interceptors at initialization. + + When generated client libraries run against older versions of `google-api-core` + (< 2.36.0) where `grpc_helpers_async.apply_channel_interceptors` is not yet + available, this fallback mutates the channel's internal interceptor lists + directly so telemetry and auth interceptors remain operational. + + Args: + channel (Any): The gRPC channel instance (typically a `grpc.aio.Channel` + or mock). + interceptors (Optional[Sequence[Any]]): A sequence of gRPC interceptor + instances or callables to register on the channel. + + Returns: + Any: The channel instance with interceptors registered. + """ + 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"), + ) + if interceptors: + for interceptor in interceptors: + matched = False + + # Path 1: Interface-matched routing. + # Inspect whether the interceptor implements specific gRPC method hooks + # (unary-unary, unary-stream, stream-unary, stream-stream). If the channel + # maintains a corresponding internal list, append the interceptor (preventing duplicates). + # A single interceptor can handle multiple call patterns, so check all mappings. + 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 + + # Path 2: Duck-typed / callable fallback. + # If the interceptor did not implement any of the specific method signatures + # above (e.g., generic callables, untyped wrappers, or test doubles), fall back + # to registering it in the primary unary-unary interceptor list if present. + 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 + + +if grpc_helpers_async is not None and hasattr( + grpc_helpers_async, "apply_channel_interceptors" +): + apply_channel_interceptors = grpc_helpers_async.apply_channel_interceptors # pragma: NO COVER +else: # pragma: NO COVER + apply_channel_interceptors = _fallback_apply_interceptors + + 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..3789e5ea83c6 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,32 @@ 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) + ): + # NOTE: Coverage tool ignores this line in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + client_options = self._client_options # pragma: NO COVER + # 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..46cbcc52e382 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,19 @@ 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) +# or transport kind arguments, 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 +) +_ASYNC_WRAP_METHOD_SUPPORTS_KIND = ( + "kind" in inspect.signature(gapic_v1.method_async.wrap_method).parameters +) + class StorageBatchOperationsTransport(abc.ABC): """Abstract transport class for StorageBatchOperations.""" @@ -57,6 +72,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 +103,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 +143,82 @@ 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. + + This method adapts dynamically across three historical generations of + `google-api-core`: + 1. Modern core with OpenTelemetry tracing (PR #18274 / #18433): + Supports `client_options`, `kind`, `method_name`, and `is_streaming`. + 2. Intermediate core with async transport discrimination (>= 2.19.1, PR #688): + Supports `kind` to avoid erroneous gRPC error mapping on REST transports, + but does not yet accept tracing arguments (`client_options`, etc.). + 3. Ancient core (< 2.19.1): + Accepts neither `client_options` nor `kind`. Both must be stripped to + prevent `TypeError: unexpected keyword argument`. + """ + # Generation 1: Full OpenTelemetry tracing support in modern google-api-core + 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) + + # Fallback for older runtime versions of google-api-core: + # Strip tracing-only arguments (client_options, method_name, is_streaming) + # unsupported in google-api-core < 2.26.0/tracing release. + for k in ["client_options", "method_name", "is_streaming"]: + kwargs.pop(k, None) + + # Generation 2 vs Generation 3 fallback check: + # In google-api-core >= 2.19.1, method_async.wrap_method introduced the `kind` + # parameter (commit 8a04ec045c1, PR #688) to distinguish gRPC from REST transports + # and prevent REST callables from being wrapped with gRPC error handlers. + # If supported, inject `kind` from the transport; otherwise (Generation 3, + # google-api-core < 2.19.1), strip `kind` to avoid a TypeError. + if _ASYNC_WRAP_METHOD_SUPPORTS_KIND and self.kind: + kwargs["kind"] = self.kind + else: + kwargs.pop("kind", 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 +231,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 +246,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 +273,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 +288,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 +303,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 +474,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..588858facb0f 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,25 @@ 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) + ): + # NOTE: Coverage tool ignores interceptor append in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + channel_interceptors.append(otel_interceptor) # pragma: NO COVER + + 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..466a49fde233 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, apply_channel_interceptors 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,28 @@ 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 + ): + # NOTE: Coverage tool ignores async interceptors in environments running + # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. + # Lifecycle: Can be lifted once lowest constraints require google-api-core >= 2.36.0. + otel_list = otel_interceptors if isinstance(otel_interceptors, (list, tuple)) else [otel_interceptors] # pragma: NO COVER + channel_interceptors.extend(otel_list) # pragma: NO COVER + + self._grpc_channel = apply_channel_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 +557,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 +575,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 +590,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 +617,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 +632,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 +647,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..387e8e5d2f8f 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 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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), + data=body, ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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 trace_ctx: + response = getattr(session, method)( + url, + timeout=timeout, + headers=headers, + params=rest_helpers.flatten_query_params(query_params, strict=True), ) - return response + trace_ctx.record_response(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..da06e57836d1 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, + apply_channel_interceptors, +) from google.cloud.storagebatchoperations_v1._compat import setup_request_id from google.auth.exceptions import MutualTLSChannelError @@ -528,3 +537,65 @@ 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 trace_ctx: + trace_ctx.record_response(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() + 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 + 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 trace_ctx: + trace_ctx.record_response(None) + finally: + # Restore _compat to normal environment + monkeypatch.undo() + importlib.reload(_compat) + + +def test_apply_channel_interceptors_compat(): + # apply_channel_interceptors is exposed from _compat and callable + dummy_channel = mock.Mock() + result = apply_channel_interceptors(dummy_channel, None) + assert result is dummy_channel 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..d839dfc05eea 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 @@ -58,6 +58,7 @@ from google.cloud.storagebatchoperations_v1.services.storage_batch_operations import StorageBatchOperationsClient from google.cloud.storagebatchoperations_v1.services.storage_batch_operations import pagers from google.cloud.storagebatchoperations_v1.services.storage_batch_operations import transports +from google.cloud.storagebatchoperations_v1.services.storage_batch_operations.client import _observability from google.cloud.storagebatchoperations_v1.types import storage_batch_operations from google.cloud.storagebatchoperations_v1.types import storage_batch_operations_types from google.longrunning import operations_pb2 # type: ignore @@ -760,6 +761,192 @@ def test_storage_batch_operations_client_client_options_from_dict(): ) +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +@pytest.mark.parametrize("capabilities_enabled, expected_options_injected", [ + pytest.param(True, True, id="otel_injection_enabled"), + pytest.param(False, False, id="otel_injection_disabled"), +]) +def test_storage_batch_operations_client_otel_channel_injection(capabilities_enabled, expected_options_injected): + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ) as mock_is_otel_enabled, + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = StorageBatchOperationsClient(transport="grpc") + + mock_is_otel_enabled.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + if expected_options_injected: + assert called_kwargs.get("client_options") == client._client_options + else: + 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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_storage_batch_operations_grpc_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + _observability, + "get_otel_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_interceptor, + 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_get_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 + + +@pytest.mark.skipif( + _observability is None, + reason="Requires google-api-core with OpenTelemetry support (>= 2.36.0)", +) +def test_storage_batch_operations_grpc_asyncio_transport_otel_channel_interceptor(): + mock_otel_interceptor = mock.Mock() + mock_channel = mock.Mock() + mock_channel._unary_unary_interceptors = [] + + with ( + mock.patch.object( + _observability, + "get_otel_async_interceptor", + return_value=mock_otel_interceptor, + ) as mock_get_async_interceptor, + 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_get_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 +6809,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 +6839,129 @@ 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" + + # Generation 1: 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" + + # Generation 1 with 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 + + # Reset kind back to grpc_asyncio + mock_kind.return_value = "grpc_asyncio" + + # Generation 2: Intermediate google-api-core (supports kind, but not tracing) + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + transport._wrap_async_method(func, client_options=options, method_name="foo", is_streaming=True) + assert "client_options" not in mock_wrap.call_args.kwargs + assert "method_name" not in mock_wrap.call_args.kwargs + assert "is_streaming" not in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs.get("kind") == "grpc_asyncio" + + # Generation 2 with default/empty kind (strips kind) + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + True, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "" + transport._wrap_async_method(func, kind="grpc_asyncio") + assert "kind" not in mock_wrap.call_args.kwargs + + # Generation 3: Ancient google-api-core (supports neither kind nor tracing) + with mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_TRACING", + False, + ), mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.transports.base._ASYNC_WRAP_METHOD_SUPPORTS_KIND", + False, + ): + mock_wrap.reset_mock() + mock_kind.return_value = "grpc_asyncio" + 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 + + 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..ac6de38eda0b 100644 --- a/packages/gapic-generator/tests/system/conftest.py +++ b/packages/gapic-generator/tests/system/conftest.py @@ -13,17 +13,21 @@ # limitations under the License. -import grpc -from unittest import mock import os -import pytest -import pytest_asyncio -from requests.adapters import HTTPAdapter - from typing import Sequence, Tuple +from unittest import mock +import grpc +import pytest +import pytest_asyncio from google.api_core.client_options import ClientOptions # type: ignore from google.showcase_v1beta1.services.echo.transports import EchoRestInterceptor +from requests.adapters import HTTPAdapter + +try: + from google.api_core import _observability +except ImportError: + _observability = None try: from google.auth.aio import credentials as ga_credentials_async @@ -34,20 +38,18 @@ HAS_GOOGLE_AUTH_AIO = False import google.auth from google.auth import credentials as ga_credentials -from google.showcase import EchoClient -from google.showcase import IdentityClient -from google.showcase import MessagingClient +from google.showcase import EchoClient, IdentityClient, MessagingClient if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": - from grpc.experimental import aio import asyncio - from google.showcase import EchoAsyncClient - from google.showcase import IdentityAsyncClient + + from google.showcase import EchoAsyncClient, IdentityAsyncClient + from grpc.experimental import aio try: from google.showcase_v1beta1.services.echo.transports import ( - AsyncEchoRestTransport, AsyncEchoRestInterceptor, + AsyncEchoRestTransport, ) HAS_ASYNC_REST_ECHO_TRANSPORT = True @@ -132,8 +134,8 @@ def callback(): return cert, key -client_options = ClientOptions() -client_options.client_cert_source = callback +default_mtls_client_options = ClientOptions() +default_mtls_client_options.client_cert_source = callback def pytest_addoption(parser): @@ -141,7 +143,9 @@ def pytest_addoption(parser): "--mtls", action="store_true", help="Run system test with mutual TLS channel" ) parser.addoption( - "--tls", action="store_true", help="Run system test with standard one-way TLS channel" + "--tls", + action="store_true", + help="Run system test with standard one-way TLS channel", ) @@ -153,6 +157,7 @@ def construct_client( channel_creator=grpc.insecure_channel, # for grpc,grpc_asyncio only credentials=ga_credentials.AnonymousCredentials(), transport_endpoint="localhost:7469", + client_options=None, ): if use_mtls: with mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"}): @@ -162,7 +167,7 @@ def construct_client( mock_ssl_cred.return_value = ssl_credentials client = client_class( credentials=credentials, - client_options=client_options, + client_options=client_options or default_mtls_client_options, ) mock_ssl_cred.assert_called_once_with( certificate_chain=cert, private_key=key @@ -173,21 +178,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 +348,9 @@ def _read_response_metadata_stream(self): def intercept_unary_unary(self, continuation, client_call_details, request): self._add_request_metadata(client_call_details) response = continuation(client_call_details, request) - metadata = [(k, str(v)) for k, v in response.initial_metadata()] + [(k, str(v)) for k, v in response.trailing_metadata()] + metadata = [(k, str(v)) for k, v in response.initial_metadata()] + [ + (k, str(v)) for k, v in response.trailing_metadata() + ] self.response_metadata = metadata return response @@ -399,7 +409,9 @@ async def _add_request_metadata(self, client_call_details): async def intercept_unary_unary(self, continuation, client_call_details, request): await self._add_request_metadata(client_call_details) response = await continuation(client_call_details, request) - metadata = [(k, str(v)) for k, v in await response.initial_metadata()] + [(k, str(v)) for k, v in await response.trailing_metadata()] + metadata = [(k, str(v)) for k, v in await response.initial_metadata()] + [ + (k, str(v)) for k, v in await response.trailing_metadata() + ] self.response_metadata = metadata return response @@ -458,9 +470,13 @@ async def intercepted_echo_grpc_async(use_mtls, use_tls): ) host = "localhost:7469" if use_mtls: - channel = grpc.aio.secure_channel(host, ssl_credentials, interceptors=[interceptor]) + channel = grpc.aio.secure_channel( + host, ssl_credentials, interceptors=[interceptor] + ) elif use_tls: - channel = grpc.aio.secure_channel(host, tls_credentials, interceptors=[interceptor]) + channel = grpc.aio.secure_channel( + host, tls_credentials, interceptors=[interceptor] + ) else: channel = grpc.aio.insecure_channel(host, interceptors=[interceptor]) transport = EchoAsyncClient.get_transport_class("grpc_asyncio")( @@ -472,6 +488,7 @@ async def intercepted_echo_grpc_async(use_mtls, use_tls): class HostNameIgnoringAdapter(HTTPAdapter): """Custom HTTPAdapter that disables hostname verification for local self-signed certs.""" + def cert_verify(self, conn, url, verify, cert): super().cert_verify(conn, url, verify, cert) conn.assert_hostname = False @@ -516,3 +533,46 @@ def intercepted_echo_rest_async(): ) return EchoAsyncClient(transport=transport), interceptor + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """Prints a Telemetry Span Compliance summary to the console. + + Enables developers to view and copy all compliance test names and + pass/fail/error states directly from CI/console output without enabling + verbose output for unrelated tests. + """ + reports = ( + terminalreporter.getreports("passed") + + terminalreporter.getreports("failed") + + terminalreporter.getreports("skipped") + + terminalreporter.getreports("error") + ) + compliance_reports = [ + r + for r in reports + if "test_span_compliance.py" in r.nodeid + and (r.when == "call" or r.failed or r.skipped) + ] + if not compliance_reports: + return + + # Aggregate status per test nodeid so setup/call/teardown don't + # produce duplicate lines. Priority: ERROR > FAILED > SKIPPED > PASSED + status_by_test = {} + for rep in compliance_reports: + test_name = rep.nodeid.split("::")[-1] + if rep.when != "call" and rep.failed: + rep_status = "ERROR" + else: + rep_status = rep.outcome.upper() + + current = status_by_test.get(test_name) + if current is None or rep_status in ("ERROR", "FAILED"): + status_by_test[test_name] = rep_status + + terminalreporter.section( + "Telemetry Span Compliance Verification", sep="=", green=True + ) + for test_name, status in status_by_test.items(): + terminalreporter.write_line(f"[{status:6}] {test_name}") 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..3949f4e29fea --- /dev/null +++ b/packages/gapic-generator/tests/system/test_span_compliance.py @@ -0,0 +1,832 @@ +# 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` & `dump_raw_spans` fixture): + 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) + +try: + from google.api_core import _observability + + HAS_TIER3_TRACING = hasattr(_observability, "_TraceContext") +except ImportError: + HAS_TIER3_TRACING = False + +if not HAS_TIER3_TRACING: + pytest.skip( + "Installed google-api-core lacks Tier 3 OpenTelemetry tracing", + 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 +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session", autouse=True) +def dump_raw_spans(): + """Serializes all captured raw spans to raw_spans_output.json upon session completion. + + This autouse session fixture runs after all tests complete, writing an immutable + diagnostic artifact showing every span's exact attributes, timing, and hierarchy + ready for upload by CI. + """ + yield + if RAW_SPANS_CATALOG: + 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..132fbac9aca7 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_tracing.py @@ -0,0 +1,237 @@ +# 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) + +try: + from google.api_core import _observability + + HAS_TIER3_TRACING = hasattr(_observability, "_TraceContext") +except ImportError: + HAS_TIER3_TRACING = False + +if not HAS_TIER3_TRACING: + pytest.skip( + "Installed google-api-core lacks Tier 3 OpenTelemetry tracing", + 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 377480cbeed3..ea657996eb68 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..a7a1dd5d2a89 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,309 @@ def get_otel_async_interceptor( request_hook=request_hook, response_hook=_grpc_client_response_hook, ) + + +_TRACE_CONTEXT_PROPAGATOR: Any = None + + +class _TraceContext: + """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`). + """ + + @staticmethod + 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. + @staticmethod + 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). + """ + 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 + + 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, + ): + 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 record_response(self, response: Any) -> None: + """Record HTTP response attributes on the active span. + + Args: + response (Any): The HTTP response object (e.g. requests.Response). + """ + span = self._span + 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 + + # Alias for method name parity with Daniel's suggested convention + record_http_response = record_response + + def record_error(self, exc: BaseException | None) -> None: + """Record an HTTP error/exception on the active span. + + Args: + exc (Optional[BaseException]): The exception raised during dispatch. + """ + span = self._span + if span is None or exc 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 + + # Alias for method name parity with Daniel's suggested convention + record_http_error = record_error + + def __enter__(self) -> "_TraceContext": + if not is_otel_capabilities_enabled(self._client_options): + return self + + 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 = ( + self._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: + self._get_trace_context_propagator().inject(resolved_headers) + except Exception: # Fail-open on header injection failure + pass + + return self + except Exception: + # Fail-open: telemetry failures must never disrupt core RPC execution + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if self._cm is not None: + try: + if exc_val is not None and self._span is not None: + self.record_error(exc_val) + finally: + self._cm.__exit__(exc_type, exc_val, exc_tb) + # Always return None so caller exceptions are never suppressed + return None + + +def trace_http_request( + 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, +) -> _TraceContext: + """Context manager factory 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: + 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. + + Args: + request (Optional[Any]): Bundled HTTP request object with .method, .url, .headers. + method (Optional[str]): Explicit HTTP method (e.g. "GET", "POST"). + url (Optional[str]): Fully qualified request URL. + url_template (Optional[str]): Parameterized path template for url.template. + headers (Optional[dict[str, Any]]): Mutable dictionary of request headers. + body (Optional[Any]): Request payload (used for body size calculation). + client_options (Optional[Union[ClientOptions, dict[str, Any]]]): Client options + containing custom endpoint and tracer provider configurations. + + Returns: + _TraceContext: An active context manager managing the HTTP client span. + """ + return _TraceContext( + request=request, + method=method, + url=url, + url_template=url_template, + headers=headers, + body=body, + client_options=client_options, + ) 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..a6e70fe7c6d8 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 @@ -41,6 +41,10 @@ class _MethodDefault(enum.Enum): """Sentinel value indicating that a retry, timeout, or compression argument was unspecified, so the default should be used.""" +_TRANSPORT_KIND_GRPC = "grpc" +_TRANSPORT_KIND_REST = "rest" +_DEFAULT_TRANSPORT_KIND = _TRANSPORT_KIND_GRPC + def _is_not_none_or_false(value): return value is not None and value is not False @@ -122,7 +126,7 @@ def _extract_rpc_identity( return method_str, service, method -def _extract_status_code(exc: Optional[Exception]) -> str: +def _extract_status_code(exc: Optional[BaseException]) -> str: """Extract canonical status code name string from an exception. Status code name strings are resolved by inspecting the following locations: @@ -132,7 +136,7 @@ def _extract_status_code(exc: Optional[Exception]) -> str: * Fallback: Defaults to the exception class name for standard Python errors. Args: - exc (Optional[Exception]): The exception to extract the status code name from. + exc (Optional[BaseException]): The exception to extract the status code name from. Returns: str: The canonical status code name (e.g. "NOT_FOUND", "UNAVAILABLE") or class name. @@ -166,7 +170,7 @@ def _extract_status_code(exc: Optional[Exception]) -> str: return target.__class__.__name__ -def _extract_error_attributes(exc: Optional[Exception]) -> dict[str, Any]: +def _extract_error_attributes(exc: Optional[BaseException]) -> dict[str, Any]: """Extract gcp.errors.* and error.type attributes from an exception. Error details and ErrorInfo structures are resolved by inspecting the following locations: @@ -176,7 +180,7 @@ def _extract_error_attributes(exc: Optional[Exception]) -> dict[str, Any]: * Unified attribute extraction: Extracts domain, reason, and metadata from ErrorInfo or exception attributes. Args: - exc (Optional[Exception]): An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object. + exc (Optional[BaseException]): An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object. Returns: dict[str, Any]: Extracted error attributes (e.g. gcp.errors.domain, error.type, gcp.errors.metadata.*). @@ -216,11 +220,26 @@ 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"): + details = target_exc.details + message = details() if callable(details) else details + if not message and isinstance(target_exc, BaseException): + message = str(target_exc) + if message: + attrs["status.message"] = str(message) + return attrs @@ -254,9 +273,11 @@ 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". """ + _SUPPORTED_TRACING_KINDS = (_TRANSPORT_KIND_GRPC, _TRANSPORT_KIND_REST) + def __init__( self, target, @@ -268,7 +289,7 @@ def __init__( method_name=None, is_streaming=False, client_info=None, - kind="grpc", + kind=_DEFAULT_TRANSPORT_KIND, ): self._target = target self._retry = retry @@ -287,11 +308,11 @@ def __init__( self._default_metadata = self._static_metadata # Configure the OpenTelemetry span factory once at initialization. - # For now, method tracing is gated to non-streaming gRPC calls where an explicit method_name is provided. + # For now, method tracing is gated to non-streaming calls where an explicit method_name is provided. self._start_span_fn = None if ( not is_streaming - and kind == "grpc" + and kind in self._SUPPORTED_TRACING_KINDS and method_name is not None and _observability.is_otel_capabilities_enabled(client_options) ): @@ -310,7 +331,7 @@ def __init__( span_name, _, _ = _extract_rpc_identity(method_name) span_attributes = { - "rpc.system.name": "grpc", + "rpc.system.name": "http" if kind.startswith("rest") else "grpc", "rpc.method": span_name, } self._start_span_fn = functools.partial( @@ -323,11 +344,7 @@ def __init__( # Gracefully disable tracing if OpenTelemetry or custom provider fails self._start_span_fn = None - def __call__( - self, *args, timeout=DEFAULT, retry=DEFAULT, compression=DEFAULT, **kwargs - ): - """Invoke the low-level RPC with retry, timeout, compression, and metadata.""" - + def _prepare_call(self, timeout, retry, compression, kwargs): if retry is DEFAULT: retry = self._retry @@ -358,23 +375,29 @@ 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 + return wrapped_func + + @contextlib.contextmanager + def _trace_span(self): 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: - result = wrapped_func(*args, **kwargs) + yield if span is not None and hasattr(span, "set_attribute"): span.set_attribute("rpc.response.status_code", "OK") - return result - except Exception as exc: + except (KeyboardInterrupt, SystemExit, GeneratorExit): + raise + except BaseException as exc: if span is not None and hasattr(span, "set_attribute"): span.set_attribute( "rpc.response.status_code", _extract_status_code(exc) @@ -383,6 +406,14 @@ def __call__( span.set_attribute(k, v) raise + def __call__( + self, *args, timeout=DEFAULT, retry=DEFAULT, compression=DEFAULT, **kwargs + ): + """Invoke the low-level RPC with retry, timeout, compression, and metadata.""" + wrapped_func = self._prepare_call(timeout, retry, compression, kwargs) + with self._trace_span(): + return wrapped_func(*args, **kwargs) + def wrap_method( func, @@ -395,7 +426,7 @@ def wrap_method( client_options=None, method_name=None, is_streaming=False, - kind="grpc", + kind=_DEFAULT_TRANSPORT_KIND, ): """Wrap an RPC method with common behavior. @@ -489,8 +520,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..2402f9f3ff95 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,13 +11,14 @@ # 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 functools +import inspect from google.api_core import grpc_helpers_async from google.api_core.gapic_v1 import client_info @@ -27,7 +28,29 @@ _GapicCallable, ) -_DEFAULT_ASYNC_TRANSPORT_KIND = "grpc_asyncio" +_TRANSPORT_KIND_GRPC_ASYNC = "grpc_asyncio" +_TRANSPORT_KIND_REST_ASYNC = "rest_asyncio" +_DEFAULT_ASYNC_TRANSPORT_KIND = _TRANSPORT_KIND_GRPC_ASYNC + + +class _AsyncGapicCallable(_GapicCallable): + """Async callable object that wraps an async RPC method with retry, timeout, metadata, and tracing.""" + + _SUPPORTED_TRACING_KINDS = ( + _TRANSPORT_KIND_GRPC_ASYNC, + _TRANSPORT_KIND_REST_ASYNC, + ) + + async def __call__( + self, *args, timeout=DEFAULT, retry=DEFAULT, compression=DEFAULT, **kwargs + ): + """Invoke the low-level async RPC with retry, timeout, compression, and metadata.""" + wrapped_func = self._prepare_call(timeout, retry, compression, kwargs) + with self._trace_span(): + res = wrapped_func(*args, **kwargs) + if inspect.isawaitable(res): + return await res + return res def wrap_method( @@ -37,9 +60,40 @@ 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. + Args: + func (Callable): The low-level async RPC method. + default_retry (Optional[google.api_core.retry_async.AsyncRetry]): The default + retry strategy. If ``None``, the method will not retry by default. + default_timeout (Optional[Union[google.api_core.timeout.Timeout, float]]): The + default timeout strategy. Can also be specified as an int or float. If + ``None``, the method will not have a timeout specified by default. + default_compression (Optional[grpc.Compression]): The default + grpc.Compression. If ``None``, the method will not have + compression specified by default. + client_info (Optional[google.api_core.gapic_v1.client_info.ClientInfo]): + Client information used to create a user-agent string that's + passed as gRPC metadata to the method. If unspecified, then + a sane default will be used. If ``None``, then no user agent + metadata will be provided to the RPC method. + 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". + 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]): Optional explicit full RPC method name + (e.g. "/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion"). + Used to identify the RPC for observability. + is_streaming (bool): Whether the RPC method is streaming. Defaults to False. + Streaming methods are currently gated and do not generate Tier 3 spans. + Returns: Callable: A new callable that takes optional ``retry``, ``timeout``, and ``compression`` arguments and applies the common error mapping, @@ -51,11 +105,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..d51ae6ea2a49 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,327 @@ 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() + from google.api_core import _observability + + with ( + mock.patch.object( + _observability, + "is_otel_capabilities_enabled", + return_value=capabilities_enabled, + autospec=True, + ), + 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.parametrize( + "kind,expected_rpc_system", + [ + ("grpc_asyncio", "grpc"), + ("rest_asyncio", "http"), + ], + ids=["grpc_asyncio", "rest_asyncio"], +) +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_enabled_success( + mock_otel, kind, expected_rpc_system +): + """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=kind, + ) + 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": expected_rpc_system, + "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 +@pytest.mark.parametrize( + "options_builder", + [ + pytest.param( + lambda p: client_options_lib.ClientOptions(tracer_provider=p), + id="client_options_object", + ), + pytest.param( + lambda p: {"tracer_provider": p}, + id="client_options_dict", + ), + ], +) +async def test_wrap_method_async_otel_tracing_client_options( + mock_otel, options_builder +): + """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 + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + client_options=options_builder(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 +@pytest.mark.parametrize("interrupt_exc", [KeyboardInterrupt, SystemExit]) +async def test_wrap_method_async_otel_tracing_interrupt_bypasses_error_attributes( + mock_otel, interrupt_exc +): + """Proves that process-level interrupts in async calls are re-raised without polluting span error attributes.""" + mock_target = mock.AsyncMock(side_effect=interrupt_exc()) + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/GetSecret", + ) + + with pytest.raises(interrupt_exc): + await wrapped() + + for call in mock_otel.span.set_attribute.call_args_list: + attr_name = call[0][0] + assert attr_name not in ( + "rpc.response.status_code", + "error.type", + "status.message", + ) + + +@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 c90c6c7bceeb..715b76a09bf8 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..a979a948338e 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,37 @@ 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("interrupt_exc", [KeyboardInterrupt, SystemExit]) +def test_wrap_method_otel_tracing_interrupt_bypasses_error_attributes( + mock_otel, interrupt_exc +): + """Proves that process-level interrupts are re-raised without polluting span error attributes.""" + mock_target = mock.Mock(side_effect=interrupt_exc()) + + wrapped = google.api_core.gapic_v1.method.wrap_method( + mock_target, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets", + ) + with pytest.raises(interrupt_exc): + wrapped() + + # The span should NOT record error attributes for process-level interruptions + for call in mock_otel.span.set_attribute.call_args_list: + attr_name = call[0][0] + assert attr_name not in ( + "rpc.response.status_code", + "error.type", + "status.message", + ) @pytest.mark.parametrize( @@ -547,7 +580,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 +590,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 +723,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 +783,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 +877,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 +904,50 @@ 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", + } + + # 11. status.message extraction from BaseException subclasses (e.g. asyncio.CancelledError) + import asyncio + + exc_cancelled_with_msg = asyncio.CancelledError("async task cancelled") + assert _extract_error_attributes(exc_cancelled_with_msg) == { + "error.type": "CancelledError", + "status.message": "async task cancelled", + } + + exc_cancelled_empty = asyncio.CancelledError() + assert _extract_error_attributes(exc_cancelled_empty) == { + "error.type": "CancelledError", + } def test_wrap_method_otel_tracing_partial_span_capabilities(mock_otel): @@ -881,9 +965,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..88066d69b3b6 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -13,6 +13,7 @@ # limitations under the License. import sys +import urllib.parse from unittest import mock import pytest @@ -94,6 +95,23 @@ def test_is_otel_capabilities_enabled_experimental_enabled_with_config(monkeypat assert _observability.is_otel_capabilities_enabled(options) +@pytest.mark.parametrize( + "boundary_options", + [ + pytest.param(None, id="options_none"), + pytest.param({}, id="options_empty_dict"), + pytest.param({"irrelevant_field": 123}, id="options_missing_tracer_provider"), + ], +) +def test_observability_handles_boundary_client_options(boundary_options): + """Verifies boundary handling when client options lack telemetry attributes.""" + enabled = _observability.is_otel_capabilities_enabled(boundary_options) + assert enabled is False + + endpoint_attrs = _observability._extract_endpoint_attributes(boundary_options) + assert endpoint_attrs == {"url.domain": "googleapis.com"} + + def test_get_tracer_provider_default(): """Proves that _get_tracer_provider returns None when no client_options are supplied.""" assert _observability._get_tracer_provider() is None @@ -542,3 +560,756 @@ 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._TraceContext._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._TraceContext._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_client_options(): + """Proves that _build_http_span_attributes extracts server.address from client_options.""" + options = ClientOptions(api_endpoint="custom.endpoint.com:443") + name, attrs, res_headers = _observability._TraceContext._build_http_span_attributes( + method="GET", + url="https://custom.endpoint.com:443/test", + client_options=options, + ) + 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._TraceContext._build_http_span_attributes( + method="DELETE", url="" + ) + assert name == "DELETE" + assert attrs["server.address"] == "" + assert attrs["server.port"] == 443 + + # Malformed URL + with mock.patch.object( + urllib.parse, "urlsplit", side_effect=ValueError("boom"), autospec=True + ): + name, attrs, _ = _observability._TraceContext._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 a context with no active span 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 ctx: + assert isinstance(ctx, _observability._TraceContext) + assert ctx._span is None + ctx.record_response(mock.Mock()) + + +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 ctx: + assert ctx._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_trace_context_record_response_success(monkeypatch): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_response(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_trace_context_record_response_error_status(monkeypatch): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_response(response) + mock_span.set_attribute.assert_any_call("http.response.status_code", 503) + mock_span.set_status.assert_called_once() + + +def test_trace_context_record_error(monkeypatch): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_error(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") + + # Also verify record_http_error alias + mock_span.reset_mock() + ctx.record_http_error(exc) + mock_span.record_exception.assert_called_once_with(exc) + + +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 ctx: + assert ctx._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_client_options(monkeypatch): + """Proves that trace_http_request respects client_options when passed as keyword argument.""" + 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.trace.status", + mock.Mock(), + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + options = ClientOptions( + api_endpoint="custom.googleapis.com:8443", + tracer_provider=mock_provider, + ) + + ctx = _observability.trace_http_request( + method="GET", + url="https://custom.googleapis.com:8443/v1/test", + client_options=options, + ) + with ctx as trace_ctx: + assert trace_ctx._span is mock_span + # Test record_response method on trace context + mock_resp = mock.Mock(status_code=200, headers={"Content-Length": "42"}) + trace_ctx.record_response(mock_resp) + 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_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 ctx: + assert ctx._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 ctx: + assert ctx._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 ctx: + assert isinstance(ctx, _observability._TraceContext) + assert ctx._span is None + ctx.record_response(mock.Mock()) + + +def test_trace_context_record_response_none_or_missing_attribute(): + """Proves that _TraceContext.record_response handles None or non-span gracefully.""" + ctx_none = _observability._TraceContext() + ctx_none._span = None + ctx_none.record_response(mock.Mock()) + + ctx_obj = _observability._TraceContext() + ctx_obj._span = object() + ctx_obj.record_response(mock.Mock()) + + +def test_trace_context_record_response_content_fallback_and_invalid_content_length( + monkeypatch, +): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_response(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" + ) + ctx.record_response(response_with_content) + mock_span.set_attribute.assert_called_once_with( + "http.response.body.size", len(b"hello-content") + ) + + +def test_trace_context_record_response_exception_handled(monkeypatch): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + # Should not raise + ctx.record_response(mock.Mock(status_code=200, headers={})) + + +def test_trace_context_record_error_none_span(): + """Proves that _TraceContext.record_error handles span=None gracefully.""" + ctx = _observability._TraceContext() + ctx._span = None + ctx.record_error(ValueError("test")) + + +def test_trace_context_record_error_with_status_code_and_empty_msg(monkeypatch): + """Proves that _TraceContext.record_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) + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_error(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_trace_context_record_error_exception_handled(monkeypatch): + """Proves that _TraceContext.record_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 + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_error(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.object( + urllib.parse, "urlsplit", side_effect=ValueError("Invalid URL"), autospec=True + ): + options = ClientOptions() + with _observability.trace_http_request( + client_options=options, + method="GET", + url="http://[invalid-url", + ) as ctx: + assert ctx._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 ctx: + assert ctx._span is mock_span + + +def test_trace_context_record_response_content_len_error(monkeypatch): + """Proves _TraceContext.record_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() + + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_response(response) + + +def test_trace_context_record_error_partial_span(monkeypatch): + """Proves that _TraceContext.record_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() + ctx = _observability._TraceContext() + ctx._span = span + ctx.record_error(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() + ctx2 = _observability._TraceContext() + ctx2._span = span2 + ctx2.record_error(ValueError("no attr span")) + assert span2.recorded is True + + +def test_trace_context_record_response_no_content_length_and_no_content(monkeypatch): + """Proves that _TraceContext.record_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={}) + ctx = _observability._TraceContext() + ctx._span = mock_span + ctx.record_response(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(), + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.status", + mock_otel.trace.status, + ) + + 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 + + mock_span.record_exception.assert_called_once_with(err) + mock_span.set_status.assert_called_once() + mock_span.set_attribute.assert_any_call("error.type", "RuntimeError") + mock_span.set_attribute.assert_any_call("status.message", "network broke") + + +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 + + +def test_trace_http_request_initialization_fails_open(monkeypatch): + """Proves that unexpected exceptions during telemetry initialization fail open and yield None.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + with mock.patch( + "opentelemetry.trace.get_tracer", + side_effect=RuntimeError("OTel crashed"), + ): + with _observability.trace_http_request( + client_options=ClientOptions(), + method="GET", + url="https://example.com", + ) as ctx: + assert isinstance(ctx, _observability._TraceContext) + assert ctx._span is None + ctx.record_response(mock.Mock())