Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
bfb40b0
feat(core): add request hook to inject GCP resource and project attri…
chalmerlowe Sep 3, 2026
87ca9b8
feat(core): implement complete T4 gRPC telemetry capture and response…
chalmerlowe Sep 9, 2026
451e17b
test(core): add comprehensive unit tests for T4 gRPC telemetry and hooks
chalmerlowe Sep 9, 2026
29de72e
refactor(core): adopt explicit _grpc_* naming for request extraction …
chalmerlowe Sep 9, 2026
7f6519f
test(core): align test names and assertions with _grpc_* naming conve…
chalmerlowe Sep 9, 2026
b0de0a4
feat(core): add url.domain, error attributes, and streamline T4 hooks
chalmerlowe Sep 10, 2026
ac095c5
feat(core): normalize gRPC span names and eliminate duplicate rpc.sys…
chalmerlowe Sep 10, 2026
ef7d77d
refactor(core): remove deferred gcp.resource.destination.id attribute
chalmerlowe Sep 10, 2026
aa2bead
feat(core): record rpc.response.status_code on wire attempt spans
chalmerlowe Sep 10, 2026
894b380
refactor(core): remove duplicate error attribute extraction in favor …
chalmerlowe Sep 10, 2026
1c1b9af
fix(observability): resolve mypy union-attr error and support environ…
chalmerlowe Sep 10, 2026
81b686c
refactor(observability): simplify response hook to record OK on succe…
chalmerlowe Sep 10, 2026
324b866
test(observability): cover request hook span edge cases for 100% bran…
chalmerlowe Sep 10, 2026
abeaf04
fix(observability): safely handle invalid port in endpoint attributes
chalmerlowe Sep 11, 2026
99a4d3d
fix(observability): ensure response hook only records OK on successfu…
chalmerlowe Sep 11, 2026
4b82c9c
refactor(observability): address review feedback on method name, url …
chalmerlowe Sep 11, 2026
0fb354a
docs(observability): clarify sync vs async behavior and specify semco…
chalmerlowe Sep 14, 2026
acce308
feat(gapic): add OpenTelemetry channel tracing to generator templates
chalmerlowe Sep 11, 2026
d337933
fix(gapic): resolve CI import errors on unreleased ClientInterceptor …
chalmerlowe Sep 11, 2026
13f1218
fix(gapic): use AnonymousCredentials in test_grpc_transport_channel_i…
chalmerlowe Sep 11, 2026
11ccd0d
test(gapic): update bazel integration goldens for otel channel tracing
chalmerlowe Sep 11, 2026
a7dad4f
ci(gapic): add OpenTelemetry test dependencies to showcase nox sessions
chalmerlowe Sep 14, 2026
858ff52
test(gapic): support client_options and otel interceptor in system te…
chalmerlowe Sep 14, 2026
b1c66e4
test(gapic): add showcase system test suite for OpenTelemetry channel…
chalmerlowe Sep 14, 2026
b409ba6
feat(gapic): broaden transport subclass check and harden tracing tests
chalmerlowe Sep 14, 2026
2059f32
refactor(gapic): guard ClientInterceptor under TYPE_CHECKING in trans…
chalmerlowe Sep 15, 2026
66a6f0e
test(gapic): synchronize NO COVER pragma in golden gRPC transports
chalmerlowe Sep 15, 2026
7ebebfa
test(gapic): support flexible import of construct_client in system tr…
chalmerlowe Sep 15, 2026
a7956a5
feat(gapic): resolve OTel interceptor in GrpcTransport and pass clien…
chalmerlowe Sep 15, 2026
cd5299a
test(gapic): update bazel integration goldens for transport tracing u…
chalmerlowe Sep 15, 2026
8b540c8
fix(gapic): resolve showcase mypy error and ensure base transport wra…
chalmerlowe Sep 16, 2026
880f5b9
feat(observability): add fallback status code and exception mapping f…
chalmerlowe Sep 16, 2026
9bb3305
test(gapic): harmonize showcase system tracing tests with env gating …
chalmerlowe Sep 16, 2026
af22028
fix(observability): ensure 100% branch coverage in error attribute ex…
chalmerlowe Sep 16, 2026
d073f2b
perf(generator): cache wrap_method tracing check at module level
chalmerlowe Sep 16, 2026
715f58b
refactor(observability): guard none span in response hook and tag int…
chalmerlowe Sep 16, 2026
c13f97a
feat(gapic): harden otel interceptor deduplication and options checki…
chalmerlowe Sep 16, 2026
b1b7ce4
test(gapic): update bazel integration goldens for interceptor hardening
chalmerlowe Sep 16, 2026
ce65620
Merge remote-tracking branch 'origin/main' into feat/otel-tracing-gap…
chalmerlowe Sep 16, 2026
d53607b
fix(ci): synchronize goldens with bazel generator and align with main
chalmerlowe Sep 16, 2026
f7f3feb
feat(gapic): wire method_name for mixin methods in base transport
chalmerlowe Sep 16, 2026
2f522bd
test(gapic): clarify test handling of abstract base transport NotImpl…
chalmerlowe Sep 17, 2026
ee5f778
refactor(testing): tighten fixture usage and standardize span asserti…
chalmerlowe Sep 17, 2026
89675ea
feat(observability): populate status.message span attribute for cross…
chalmerlowe Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/gapic-generator/gapic/schema/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}
2 changes: 2 additions & 0 deletions packages/gapic-generator/gapic/schema/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,8 @@ class MixinMethod:
name: str
request_type: str
response_type: str
rpc_name: str = ""



@dataclasses.dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ from collections import OrderedDict
import functools
{% endif %}
from http import HTTPStatus
import inspect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to be unused

import json
import logging as std_logging
import os
Expand Down Expand Up @@ -53,6 +54,13 @@ try:
except ImportError: # pragma: NO COVER
CLIENT_LOGGING_SUPPORTED = False

# Optional: OpenTelemetry tracing capabilities for grpc channel injection
# Note: _observability was added in google-api-core 2.36.0+; guard for older versions
try:
from google.api_core import _observability # type: ignore[attr-defined]
except ImportError: # pragma: NO COVER
_observability = None # type: ignore[assignment]

_LOGGER = std_logging.getLogger(__name__)

{% filter sort_lines %}
Expand Down Expand Up @@ -314,17 +322,17 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
client_cert_source = mtls.default_client_cert_source()
return client_cert_source


def _validate_universe_domain(self):
"""Validates client's and credentials' universe domains are consistent.

Returns:
bool: True iff the configured universe domain is valid.

Raises:
ValueError: If the configured universe domain is not valid.
"""

# NOTE (b/349488459): universe validation is disabled until further notice.
return True

Expand Down Expand Up @@ -355,21 +363,21 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
@property
def api_endpoint(self) -> str:
"""Return the API endpoint used by the client instance.

Returns:
str: The API endpoint used by the client instance.
"""
return self._api_endpoint

@property
def universe_domain(self) -> str:
"""Return the universe domain used by the client instance.

Returns:
str: The universe domain used by the client instance.
"""
return self._universe_domain

def __init__(self, *,
credentials: Optional[ga_credentials.Credentials] = None,
transport: Optional[Union[str, {{ service.name }}Transport, Callable[..., {{ service.name }}Transport]]] = None,
Expand Down Expand Up @@ -397,8 +405,8 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
{% endif %}
client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]):
Custom options for the client.
1. The ``api_endpoint`` property can be used to override the

1. The ``api_endpoint`` property can be used to override the
default endpoint provided by the client when ``transport`` is
not explicitly provided. Only if this property is not set and
``transport`` was not explicitly provided, the endpoint is
Expand All @@ -415,7 +423,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
not provided, the default SSL client certificate will be used if
present. If GOOGLE_API_USE_CLIENT_CERTIFICATE is "false" or not
set, no client certificate will be used.

3. The ``universe_domain`` property can be used to override the
default "googleapis.com" universe. Note that the ``api_endpoint``
property still takes precedence; and ``universe_domain`` is
Expand Down Expand Up @@ -473,7 +481,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
self._transport = cast({{ service.name }}Transport, transport)
self._api_endpoint = self._transport.host

self._api_endpoint = (self._api_endpoint or
self._api_endpoint = (self._api_endpoint or
get_api_endpoint(
api_override=self._client_options.api_endpoint,
universe_domain=self._universe_domain,
Expand Down Expand Up @@ -531,19 +539,34 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
else cast(Callable[..., {{ service.name }}Transport], transport)
)
{% endif %}
# When OpenTelemetry tracing is enabled, pass client_options to the transport
# so it can wire tracing interceptors and method spans.
client_options = None
if (
_observability is not None
and _observability.is_otel_capabilities_enabled(self._client_options)
and (
not isinstance(transport_init, type)
or issubclass(transport_init, {{ service.grpc_transport_name }})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to be so selective about which transports we pass client_options to? I was thinking by moving the interceptor to the transport, we could pass the same init args to them all

(I wonder if grpc_transport_name here could cause problems specifically, if there are any http-only clients without this set)

)
):
client_options = self._client_options

# initialize with the provided callable or the passed in class
self._transport = transport_init(
credentials=credentials,
credentials_file=self._client_options.credentials_file,
host=self._api_endpoint,
scopes=self._client_options.scopes,
client_cert_source_for_mtls=self._client_cert_source,
quota_project_id=self._client_options.quota_project_id,
client_info=client_info,
always_use_jwt_access=True,
api_audience=self._client_options.api_audience,
)

transport_kwargs = {
"credentials": credentials,
"credentials_file": self._client_options.credentials_file,
"host": self._api_endpoint,
"scopes": self._client_options.scopes,
"client_cert_source_for_mtls": self._client_cert_source,
"quota_project_id": self._client_options.quota_project_id,
"client_info": client_info,
"always_use_jwt_access": True,
"api_audience": self._client_options.api_audience,
**({"client_options": client_options} if client_options is not None else {}),
}
self._transport = transport_init(**transport_kwargs)

if "async" not in str(self._transport):
if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG): # pragma: NO COVER
_LOGGER.debug(
Expand Down Expand Up @@ -827,7 +850,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
gapic_v1.routing_header.to_grpc_metadata(
(("resource", request_pb.resource),)),
)

# Validate the universe domain.
self._validate_universe_domain()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
{% 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 %}
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
Expand Down Expand Up @@ -53,6 +55,13 @@ from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + ser
DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo(gapic_version=package_version.__version__)
DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__

# Check once at module load time whether google-api-core's wrap_method supports
# OpenTelemetry tracing arguments (client_options, method_name, is_streaming, kind)
# to avoid recurring inspect.signature latency during client instantiation.
_WRAP_METHOD_SUPPORTS_TRACING = (
"client_options" in inspect.signature(gapic_v1.method.wrap_method).parameters
)


class {{ service.name }}Transport(abc.ABC):
"""Abstract transport class for {{ service.name }}."""
Expand All @@ -75,6 +84,7 @@ class {{ service.name }}Transport(abc.ABC):
client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO,
always_use_jwt_access: Optional[bool] = False,
api_audience: Optional[str] = None,
client_options: Optional[Union[client_options_lib.ClientOptions, dict]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be added to http too, to be consistent?

We should at least add a **kwarg, so new arguments can be passed through?

**kwargs,
) -> None:
"""Instantiate the transport.
Expand Down Expand Up @@ -105,6 +115,9 @@ class {{ service.name }}Transport(abc.ABC):
to the service that will be set when using certain 3rd party
authentication flows. Audience is typically a resource identifier.
If not set, the host value will be used as a default.
client_options (Optional[Union[google.api_core.client_options.ClientOptions, dict]]):
Custom options for the client, containing options such as
custom OpenTelemetry tracer providers.
"""
{% if service.any_extended_operations_methods %}
self._extended_operations_services: Dict[str, Any] = {}
Expand Down Expand Up @@ -145,17 +158,39 @@ class {{ service.name }}Transport(abc.ABC):
host += ':443'
self._host = host

self._client_options = client_options
self._wrap_with_tracing = _WRAP_METHOD_SUPPORTS_TRACING

self._wrapped_methods: Dict[Callable, Callable] = {}

@property
def host(self):
return self._host

def _wrap_method(self, func, *args, **kwargs):
if self._wrap_with_tracing:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can't this just be if _WRAP_METHOD_SUPPORTS_TRACING? It doesn't seem like it should be tied to the transport

kwargs["client_options"] = self._client_options
try:
kwargs["kind"] = self.kind
# The abstract BaseTransport class raises NotImplementedError for the kind property.
# Concrete transport subclasses (gRPC, REST) override kind, so this exception handler
# is unreachable during normal execution. Excluded from coverage check.
except NotImplementedError: # pragma: NO COVER
pass
return gapic_v1.method.wrap_method(func, *args, **kwargs)
# The fallback below strips tracing-specific arguments when an older version
# of google-api-core is installed (which does not accept client_options, etc.).
# Excluded from coverage because our CI and testing environments always install
# a modern version of google-api-core that supports tracing.
for k in ["client_options", "method_name", "is_streaming", "kind"]: # pragma: NO COVER
kwargs.pop(k, None) # pragma: NO COVER
return gapic_v1.method.wrap_method(func, *args, **kwargs) # pragma: NO COVER

def _prep_wrapped_messages(self, client_info):
# Precompute the wrapped methods.
self._wrapped_methods = {
{% for method in service.methods.values() %}
self.{{ method.transport_safe_name|snake_case }}: gapic_v1.method.wrap_method(
self.{{ method.transport_safe_name|snake_case }}: self._wrap_method(
self.{{ method.transport_safe_name|snake_case }},
{% if method.retry %}
default_retry=retries.Retry(
Expand All @@ -178,13 +213,18 @@ class {{ service.name }}Transport(abc.ABC):
{% endif %}
default_timeout={{ method.timeout }},
client_info=client_info,
method_name="{{ '.'.join(method.meta.address.package) }}.{{ service.name }}/{{ method.name }}",
{% if method.client_streaming or method.server_streaming %}
is_streaming=True,
{% endif %}
),
{% endfor %}{# method in service.methods.values() #}
{% for method_name in api.mixin_api_methods.keys() %}
self.{{ method_name|snake_case }}: gapic_v1.method.wrap_method(
self.{{ method_name|snake_case }}: self._wrap_method(
self.{{ method_name|snake_case }},
default_timeout=None,
client_info=client_info,
method_name="{{ api.mixin_api_signatures[method_name].rpc_name }}",
),
{% endfor %} {# method_name in api.mixin_api_methods.keys() #}
}
Expand Down
Loading
Loading