From bfb40b0151ea38c806ecb8f5d41f8269065e4abe Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 3 Sep 2026 06:30:13 -0400 Subject: [PATCH 01/27] feat(core): add request hook to inject GCP resource and project attributes --- .../google/api_core/_observability.py | 55 ++++++++++++++++++- .../tests/unit/test_observability.py | 47 +++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index f101cec28f5c..262b9beda34d 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -64,6 +64,55 @@ def is_otel_capabilities_enabled( return False +def _extract_t4_attributes(request: Any) -> dict[str, Any]: + """Extracts Google Cloud semantic and resource attributes from a gRPC request object. + + Args: + request: The gRPC request object. + + Returns: + dict[str, Any]: A dictionary of semantic attributes. + """ + attrs: dict[str, Any] = {} + if request is None: + return attrs + + name = getattr(request, "name", None) + if name and isinstance(name, str): + attrs["gcp.resource.name"] = name + if "projects/" in name: + parts = name.split("/") + try: + idx = parts.index("projects") + if idx + 1 < len(parts): + attrs["gcp.project_id"] = parts[idx + 1] + except ValueError: + pass + + parent = getattr(request, "parent", None) + if parent and isinstance(parent, str): + attrs["gcp.resource.parent"] = parent + if "gcp.project_id" not in attrs and "projects/" in parent: + parts = parent.split("/") + try: + idx = parts.index("projects") + if idx + 1 < len(parts): + attrs["gcp.project_id"] = parts[idx + 1] + except ValueError: + pass + + return attrs + + +def _client_request_hook(span: Any, request: Any) -> None: + """OpenTelemetry client request hook to inject GCP resource attributes into the span.""" + if span is None or not getattr(span, "is_recording", lambda: True)(): + return + attrs = _extract_t4_attributes(request) + for key, value in attrs.items(): + span.set_attribute(key, value) + + def _get_tracer_provider( client_options: ClientOptions | dict[str, Any] | None = None, ) -> opentelemetry.trace.TracerProvider | None: @@ -102,7 +151,8 @@ def get_otel_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] interceptor: ClientInterceptor = otel_grpc.client_interceptor( - tracer_provider=_get_tracer_provider(client_options) + tracer_provider=_get_tracer_provider(client_options), + request_hook=_client_request_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -131,5 +181,6 @@ def get_otel_async_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] return otel_grpc.aio_client_interceptors( - tracer_provider=_get_tracer_provider(client_options) + tracer_provider=_get_tracer_provider(client_options), + request_hook=_client_request_hook, ) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 8e8964e66264..a512ea068981 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -162,7 +162,8 @@ def test_get_otel_interceptor_enabled(monkeypatch): assert callable(interceptor) mock_otel_grpc.client_interceptor.assert_called_once_with( - tracer_provider=mock_tracer_provider + tracer_provider=mock_tracer_provider, + request_hook=_observability._client_request_hook, ) result = interceptor(mock_raw_channel) @@ -251,5 +252,47 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): result = _observability.get_otel_async_interceptor(client_options=options) assert result is mock_async_interceptors mock_otel_grpc.aio_client_interceptors.assert_called_once_with( - tracer_provider=mock_tracer_provider + tracer_provider=mock_tracer_provider, + request_hook=_observability._client_request_hook, ) + + +def test_extract_t4_attributes(): + """Proves that _extract_t4_attributes correctly extracts GCP resource name, + parent, and project ID from gRPC request objects. + """ + assert _observability._extract_t4_attributes(None) == {} + + # With name + req_name = mock.Mock(spec=["name"], name="req_name") + req_name.name = "projects/my-project/secrets/my-secret" + attrs = _observability._extract_t4_attributes(req_name) + assert attrs["gcp.resource.name"] == "projects/my-project/secrets/my-secret" + assert attrs["gcp.project_id"] == "my-project" + + # With parent + req_parent = mock.Mock(spec=["parent"], name="req_parent") + req_parent.parent = "projects/parent-project" + attrs = _observability._extract_t4_attributes(req_parent) + assert attrs["gcp.resource.parent"] == "projects/parent-project" + assert attrs["gcp.project_id"] == "parent-project" + + +def test_client_request_hook(): + """Proves that _client_request_hook attaches extracted T4 attributes to recording spans.""" + # Non-recording span should not set attributes + mock_span_non_rec = mock.Mock() + mock_span_non_rec.is_recording.return_value = False + _observability._client_request_hook(mock_span_non_rec, mock.Mock()) + mock_span_non_rec.set_attribute.assert_not_called() + + # Recording span should set attributes + mock_span_rec = mock.Mock() + mock_span_rec.is_recording.return_value = True + req = mock.Mock(name="req") + req.name = "projects/my-proj/secrets/s1" + _observability._client_request_hook(mock_span_rec, req) + mock_span_rec.set_attribute.assert_any_call( + "gcp.resource.name", "projects/my-proj/secrets/s1" + ) + mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj") From 87ca9b865fd794952bf5936e669332a59f36465e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 9 Sep 2026 08:42:46 -0400 Subject: [PATCH 02/27] feat(core): implement complete T4 gRPC telemetry capture and response hook - Add rpc.system.name: 'grpc' - Extract server.address and server.port from client options endpoint - Extract gcp.grpc.resend_count from request resend count - Extract gcp.resource.destination.id from request name or parent - Add _client_response_hook for status code, error.type, and status.message - Plumb response_hook into get_otel_interceptor and get_otel_async_interceptor --- .../google/api_core/_observability.py | 158 ++++++++++++++---- 1 file changed, 126 insertions(+), 32 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 262b9beda34d..b3309bae8a63 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: # flake8: grpc, trace, and ClientInterceptor are imported only for static analysis and type annotations - # The `# noqa: F401` comment avoids flake8 "imported but not used" errors. + # The 'noqa: F401' comment avoids flake8 "imported but not used" errors. import grpc # noqa: F401 import opentelemetry.trace # noqa: F401 @@ -64,8 +64,56 @@ def is_otel_capabilities_enabled( return False +_STATUS_CODE_NAMES = { + 0: "OK", + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", +} + + +def _extract_endpoint_attributes( + client_options: ClientOptions | dict[str, Any] | None = None, +) -> dict[str, Any]: + """Extracts server.address and server.port from client options if present.""" + attrs: dict[str, Any] = {} + endpoint = None + if isinstance(client_options, dict): + endpoint = client_options.get("api_endpoint") + elif client_options is not None: + endpoint = getattr(client_options, "api_endpoint", None) + + if endpoint and isinstance(endpoint, str): + clean = endpoint.replace("http://", "").replace("https://", "").strip("/") + if clean: + if ":" in clean: + host, port_str = clean.split(":", 1) + attrs["server.address"] = host + try: + attrs["server.port"] = int(port_str) + except ValueError: + attrs["server.port"] = 443 + else: + attrs["server.address"] = clean + attrs["server.port"] = 443 + return attrs + + def _extract_t4_attributes(request: Any) -> dict[str, Any]: - """Extracts Google Cloud semantic and resource attributes from a gRPC request object. + """Extracts Google Cloud T4 semantic and resource attributes from a gRPC request object. Args: request: The gRPC request object. @@ -73,44 +121,74 @@ def _extract_t4_attributes(request: Any) -> dict[str, Any]: Returns: dict[str, Any]: A dictionary of semantic attributes. """ - attrs: dict[str, Any] = {} + attrs: dict[str, Any] = { + "rpc.system.name": "grpc", + } if request is None: return attrs - name = getattr(request, "name", None) - if name and isinstance(name, str): - attrs["gcp.resource.name"] = name - if "projects/" in name: - parts = name.split("/") - try: - idx = parts.index("projects") - if idx + 1 < len(parts): - attrs["gcp.project_id"] = parts[idx + 1] - except ValueError: - pass + resend_count = getattr(request, "resend_count", None) + if isinstance(resend_count, int) and resend_count > 0: + attrs["gcp.grpc.resend_count"] = resend_count - parent = getattr(request, "parent", None) - if parent and isinstance(parent, str): - attrs["gcp.resource.parent"] = parent - if "gcp.project_id" not in attrs and "projects/" in parent: - parts = parent.split("/") - try: - idx = parts.index("projects") - if idx + 1 < len(parts): - attrs["gcp.project_id"] = parts[idx + 1] - except ValueError: - pass + name = getattr(request, "name", None) + if isinstance(name, str) and name: + attrs["gcp.resource.destination.id"] = name + else: + parent = getattr(request, "parent", None) + if isinstance(parent, str) and parent: + attrs["gcp.resource.destination.id"] = parent return attrs -def _client_request_hook(span: Any, request: Any) -> None: - """OpenTelemetry client request hook to inject GCP resource attributes into the span.""" +def _make_client_request_hook( + endpoint_attrs: dict[str, Any] | None = None, +) -> Callable[[Any, Any], None]: + """Creates an OpenTelemetry client request hook with optional endpoint attributes.""" + + def client_request_hook(span: Any, request: Any) -> None: + if span is None or not getattr(span, "is_recording", lambda: True)(): + return + attrs = _extract_t4_attributes(request) + if endpoint_attrs: + attrs.update(endpoint_attrs) + for key, value in attrs.items(): + span.set_attribute(key, value) + + return client_request_hook + + +_client_request_hook = _make_client_request_hook() + + +def _client_response_hook(span: Any, response: Any) -> None: + """OpenTelemetry client response hook to inject gRPC response status attributes into the span.""" if span is None or not getattr(span, "is_recording", lambda: True)(): return - attrs = _extract_t4_attributes(request) - for key, value in attrs.items(): - span.set_attribute(key, value) + + status_str = "OK" + code_fn = getattr(response, "code", None) + if callable(code_fn): + try: + code_val = code_fn() + status_str = getattr(code_val, "name", None) or _STATUS_CODE_NAMES.get( + code_val, str(code_val) + ) + except Exception: + pass + + span.set_attribute("rpc.response.status_code", status_str) + if status_str != "OK": + span.set_attribute("error.type", status_str) + details_fn = getattr(response, "details", None) + if callable(details_fn): + try: + details = details_fn() + if details: + span.set_attribute("status.message", str(details)) + except Exception: + pass def _get_tracer_provider( @@ -150,9 +228,17 @@ def get_otel_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + endpoint_attrs = _extract_endpoint_attributes(client_options) + request_hook = ( + _make_client_request_hook(endpoint_attrs) + if endpoint_attrs + else _client_request_hook + ) + interceptor: ClientInterceptor = otel_grpc.client_interceptor( tracer_provider=_get_tracer_provider(client_options), - request_hook=_client_request_hook, + request_hook=request_hook, + response_hook=_client_response_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -180,7 +266,15 @@ def get_otel_async_interceptor( # Ignored by mypy: Optional dependency only loaded if early-return is skipped import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] + endpoint_attrs = _extract_endpoint_attributes(client_options) + request_hook = ( + _make_client_request_hook(endpoint_attrs) + if endpoint_attrs + else _client_request_hook + ) + return otel_grpc.aio_client_interceptors( tracer_provider=_get_tracer_provider(client_options), - request_hook=_client_request_hook, + request_hook=request_hook, + response_hook=_client_response_hook, ) From 451e17bacfe278674f334c7968107de6f0b5206f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 9 Sep 2026 08:42:52 -0400 Subject: [PATCH 03/27] test(core): add comprehensive unit tests for T4 gRPC telemetry and hooks - Test endpoint attribute parsing across host/port variations - Test destination id and resend count extraction - Test client request and response hooks covering all status and error cases - Test interceptor creation and custom endpoint attribute propagation - Achieve 100% statement and branch coverage on _observability.py --- .../tests/unit/test_observability.py | 279 ++++++++++++++++-- 1 file changed, 256 insertions(+), 23 deletions(-) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index a512ea068981..5bfa72df64cd 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 types from unittest import mock import pytest @@ -164,6 +165,7 @@ def test_get_otel_interceptor_enabled(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider, request_hook=_observability._client_request_hook, + response_hook=_observability._client_response_hook, ) result = interceptor(mock_raw_channel) @@ -254,28 +256,84 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): mock_otel_grpc.aio_client_interceptors.assert_called_once_with( tracer_provider=mock_tracer_provider, request_hook=_observability._client_request_hook, + response_hook=_observability._client_response_hook, ) -def test_extract_t4_attributes(): - """Proves that _extract_t4_attributes correctly extracts GCP resource name, - parent, and project ID from gRPC request objects. - """ - assert _observability._extract_t4_attributes(None) == {} - - # With name - req_name = mock.Mock(spec=["name"], name="req_name") - req_name.name = "projects/my-project/secrets/my-secret" - attrs = _observability._extract_t4_attributes(req_name) - assert attrs["gcp.resource.name"] == "projects/my-project/secrets/my-secret" - assert attrs["gcp.project_id"] == "my-project" +def test_extract_endpoint_attributes(): + """Proves that _extract_endpoint_attributes correctly parses server.address and server.port.""" + # None or empty options + assert _observability._extract_endpoint_attributes(None) == {} + assert _observability._extract_endpoint_attributes({}) == {} + assert ( + _observability._extract_endpoint_attributes(ClientOptions(api_endpoint=None)) + == {} + ) - # With parent - req_parent = mock.Mock(spec=["parent"], name="req_parent") - req_parent.parent = "projects/parent-project" - attrs = _observability._extract_t4_attributes(req_parent) - assert attrs["gcp.resource.parent"] == "projects/parent-project" - assert attrs["gcp.project_id"] == "parent-project" + # Dict options with standard endpoint + dict_opts = {"api_endpoint": "secretmanager.googleapis.com"} + attrs = _observability._extract_endpoint_attributes(dict_opts) + assert attrs["server.address"] == "secretmanager.googleapis.com" + assert attrs["server.port"] == 443 + + # ClientOptions with custom port + custom_opts = ClientOptions(api_endpoint="https://my-custom-host.com:8443/") + attrs = _observability._extract_endpoint_attributes(custom_opts) + assert attrs["server.address"] == "my-custom-host.com" + assert attrs["server.port"] == 8443 + + # Invalid port string falls back to 443 + invalid_port_opts = ClientOptions(api_endpoint="my-custom-host.com:invalid_port") + attrs = _observability._extract_endpoint_attributes(invalid_port_opts) + assert attrs["server.address"] == "my-custom-host.com" + assert attrs["server.port"] == 443 + + +@pytest.mark.parametrize( + "req,expected_attrs", + [ + (None, {"rpc.system.name": "grpc"}), + (types.SimpleNamespace(), {"rpc.system.name": "grpc"}), + ( + types.SimpleNamespace(name="projects/p1/secrets/s1"), + { + "rpc.system.name": "grpc", + "gcp.resource.destination.id": "projects/p1/secrets/s1", + }, + ), + ( + types.SimpleNamespace(parent="projects/parent-p1"), + { + "rpc.system.name": "grpc", + "gcp.resource.destination.id": "projects/parent-p1", + }, + ), + ( + types.SimpleNamespace( + name="projects/p1/secrets/s1", parent="projects/parent-p1" + ), + { + "rpc.system.name": "grpc", + "gcp.resource.destination.id": "projects/p1/secrets/s1", + }, + ), + ( + types.SimpleNamespace(name="projects/p1/secrets/s1", resend_count=2), + { + "rpc.system.name": "grpc", + "gcp.resource.destination.id": "projects/p1/secrets/s1", + "gcp.grpc.resend_count": 2, + }, + ), + ( + types.SimpleNamespace(resend_count=0), + {"rpc.system.name": "grpc"}, + ), + ], +) +def test_extract_t4_attributes(req, expected_attrs): + """Proves that _extract_t4_attributes extracts all T4 gRPC attributes.""" + assert _observability._extract_t4_attributes(req) == expected_attrs def test_client_request_hook(): @@ -286,13 +344,188 @@ def test_client_request_hook(): _observability._client_request_hook(mock_span_non_rec, mock.Mock()) mock_span_non_rec.set_attribute.assert_not_called() - # Recording span should set attributes + # None span should safely return + _observability._client_request_hook(None, mock.Mock()) + + # Recording span with default hook mock_span_rec = mock.Mock() mock_span_rec.is_recording.return_value = True - req = mock.Mock(name="req") - req.name = "projects/my-proj/secrets/s1" + req = types.SimpleNamespace(name="projects/my-proj/secrets/s1", resend_count=1) _observability._client_request_hook(mock_span_rec, req) + mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc") mock_span_rec.set_attribute.assert_any_call( - "gcp.resource.name", "projects/my-proj/secrets/s1" + "gcp.resource.destination.id", "projects/my-proj/secrets/s1" + ) + mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1) + + # Custom hook with endpoint attributes + endpoint_hook = _observability._make_client_request_hook( + {"server.address": "custom.api.com", "server.port": 443} + ) + mock_span_custom = mock.Mock() + mock_span_custom.is_recording.return_value = True + endpoint_hook(mock_span_custom, req) + mock_span_custom.set_attribute.assert_any_call("server.address", "custom.api.com") + mock_span_custom.set_attribute.assert_any_call("server.port", 443) + + +def test_client_response_hook(): + """Proves that _client_response_hook sets rpc.response.status_code, error.type, and status.message.""" + # Non-recording span should not set attributes + mock_span_non_rec = mock.Mock() + mock_span_non_rec.is_recording.return_value = False + _observability._client_response_hook(mock_span_non_rec, mock.Mock()) + mock_span_non_rec.set_attribute.assert_not_called() + + # None span should safely return + _observability._client_response_hook(None, mock.Mock()) + + # Response with no code method defaults to OK + mock_span_ok = mock.Mock() + mock_span_ok.is_recording.return_value = True + _observability._client_response_hook(mock_span_ok, mock.Mock(spec=[])) + mock_span_ok.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") + + # Response with StatusCode object having name (e.g. OK) + mock_span_code_obj = mock.Mock() + mock_span_code_obj.is_recording.return_value = True + mock_resp_ok = mock.Mock() + mock_code_ok = mock.Mock() + mock_code_ok.name = "OK" + mock_resp_ok.code.return_value = mock_code_ok + _observability._client_response_hook(mock_span_code_obj, mock_resp_ok) + mock_span_code_obj.set_attribute.assert_called_once_with( + "rpc.response.status_code", "OK" + ) + + # Response with error status (e.g. integer 14 -> UNAVAILABLE) and details + mock_span_err = mock.Mock() + mock_span_err.is_recording.return_value = True + mock_resp_err = mock.Mock() + mock_resp_err.code.return_value = 14 + mock_resp_err.details.return_value = "Service temporarily unavailable" + _observability._client_response_hook(mock_span_err, mock_resp_err) + mock_span_err.set_attribute.assert_any_call( + "rpc.response.status_code", "UNAVAILABLE" + ) + mock_span_err.set_attribute.assert_any_call("error.type", "UNAVAILABLE") + mock_span_err.set_attribute.assert_any_call( + "status.message", "Service temporarily unavailable" + ) + + # Response where code() raises an exception is handled gracefully + mock_span_exc = mock.Mock() + mock_span_exc.is_recording.return_value = True + mock_resp_exc = mock.Mock() + mock_resp_exc.code.side_effect = RuntimeError("Broken call") + _observability._client_response_hook(mock_span_exc, mock_resp_exc) + mock_span_exc.set_attribute.assert_called_once_with( + "rpc.response.status_code", "OK" + ) + + # Response with error status but no details method + mock_span_no_det = mock.Mock() + mock_span_no_det.is_recording.return_value = True + mock_resp_no_det = mock.Mock(spec=["code"]) + mock_resp_no_det.code.return_value = 14 + _observability._client_response_hook(mock_span_no_det, mock_resp_no_det) + mock_span_no_det.set_attribute.assert_any_call( + "rpc.response.status_code", "UNAVAILABLE" + ) + mock_span_no_det.set_attribute.assert_any_call("error.type", "UNAVAILABLE") + + # Response with error status where details() returns empty/None + mock_span_empty_det = mock.Mock() + mock_span_empty_det.is_recording.return_value = True + mock_resp_empty_det = mock.Mock() + mock_resp_empty_det.code.return_value = 14 + mock_resp_empty_det.details.return_value = "" + _observability._client_response_hook(mock_span_empty_det, mock_resp_empty_det) + mock_span_empty_det.set_attribute.assert_any_call( + "rpc.response.status_code", "UNAVAILABLE" + ) + + # Response with error status where details() raises an exception + mock_span_exc_det = mock.Mock() + mock_span_exc_det.is_recording.return_value = True + mock_resp_exc_det = mock.Mock() + mock_resp_exc_det.code.return_value = 14 + mock_resp_exc_det.details.side_effect = RuntimeError("Details broken") + _observability._client_response_hook(mock_span_exc_det, mock_resp_exc_det) + mock_span_exc_det.set_attribute.assert_any_call( + "rpc.response.status_code", "UNAVAILABLE" + ) + + +def test_extract_endpoint_attributes_empty_clean(): + """Proves that endpoint consisting only of slashes/protocol results in empty attrs.""" + assert ( + _observability._extract_endpoint_attributes( + ClientOptions(api_endpoint="http:///") + ) + == {} + ) + + +def test_get_otel_interceptor_with_api_endpoint(monkeypatch): + """Proves that get_otel_interceptor injects server.address and server.port when api_endpoint is set.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + options = ClientOptions(api_endpoint="secretmanager.googleapis.com:443") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + 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_grpc + ) + + interceptor = _observability.get_otel_interceptor(client_options=options) + assert callable(interceptor) + + # Verify custom request hook was passed + args, kwargs = mock_otel_grpc.client_interceptor.call_args + req_hook = kwargs["request_hook"] + assert req_hook is not _observability._client_request_hook + + # Test invoking the custom hook + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + req_hook(mock_span, None) + mock_span.set_attribute.assert_any_call( + "server.address", "secretmanager.googleapis.com" + ) + mock_span.set_attribute.assert_any_call("server.port", 443) + + +def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): + """Proves that get_otel_async_interceptor injects server.address and server.port when api_endpoint is set.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + options = ClientOptions(api_endpoint="secretmanager.googleapis.com:8443") + + mock_otel = mock.Mock() + mock_otel_grpc = mock_otel.instrumentation.grpc + 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_grpc + ) + + result = _observability.get_otel_async_interceptor(client_options=options) + assert result is not None + + args, kwargs = mock_otel_grpc.aio_client_interceptors.call_args + req_hook = kwargs["request_hook"] + assert req_hook is not _observability._client_request_hook + + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + req_hook(mock_span, None) + mock_span.set_attribute.assert_any_call( + "server.address", "secretmanager.googleapis.com" ) - mock_span_rec.set_attribute.assert_any_call("gcp.project_id", "my-proj") + mock_span.set_attribute.assert_any_call("server.port", 8443) From 29de72e9bbc246d60f045ba2d008984500e66ba6 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 9 Sep 2026 09:50:04 -0400 Subject: [PATCH 04/27] refactor(core): adopt explicit _grpc_* naming for request extraction and hooks - Rename _extract_t4_attributes to _extract_grpc_request_attributes - Rename _make_client_request_hook to _make_grpc_client_request_hook - Rename _client_request_hook to _grpc_client_request_hook - Rename _client_response_hook to _grpc_client_response_hook - Preserve generic _extract_endpoint_attributes for shared transport usage --- .../google/api_core/_observability.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index b3309bae8a63..a537c2756207 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -112,7 +112,7 @@ def _extract_endpoint_attributes( return attrs -def _extract_t4_attributes(request: Any) -> dict[str, Any]: +def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: """Extracts Google Cloud T4 semantic and resource attributes from a gRPC request object. Args: @@ -142,15 +142,15 @@ def _extract_t4_attributes(request: Any) -> dict[str, Any]: return attrs -def _make_client_request_hook( +def _make_grpc_client_request_hook( endpoint_attrs: dict[str, Any] | None = None, ) -> Callable[[Any, Any], None]: - """Creates an OpenTelemetry client request hook with optional endpoint attributes.""" + """Creates an OpenTelemetry gRPC client request hook with optional endpoint attributes.""" def client_request_hook(span: Any, request: Any) -> None: if span is None or not getattr(span, "is_recording", lambda: True)(): return - attrs = _extract_t4_attributes(request) + attrs = _extract_grpc_request_attributes(request) if endpoint_attrs: attrs.update(endpoint_attrs) for key, value in attrs.items(): @@ -159,11 +159,11 @@ def client_request_hook(span: Any, request: Any) -> None: return client_request_hook -_client_request_hook = _make_client_request_hook() +_grpc_client_request_hook = _make_grpc_client_request_hook() -def _client_response_hook(span: Any, response: Any) -> None: - """OpenTelemetry client response hook to inject gRPC response status attributes into the span.""" +def _grpc_client_response_hook(span: Any, response: Any) -> None: + """OpenTelemetry gRPC client response hook to inject response status attributes into the span.""" if span is None or not getattr(span, "is_recording", lambda: True)(): return @@ -230,15 +230,15 @@ def get_otel_interceptor( endpoint_attrs = _extract_endpoint_attributes(client_options) request_hook = ( - _make_client_request_hook(endpoint_attrs) + _make_grpc_client_request_hook(endpoint_attrs) if endpoint_attrs - else _client_request_hook + else _grpc_client_request_hook ) interceptor: ClientInterceptor = otel_grpc.client_interceptor( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, - response_hook=_client_response_hook, + response_hook=_grpc_client_response_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -268,13 +268,13 @@ def get_otel_async_interceptor( endpoint_attrs = _extract_endpoint_attributes(client_options) request_hook = ( - _make_client_request_hook(endpoint_attrs) + _make_grpc_client_request_hook(endpoint_attrs) if endpoint_attrs - else _client_request_hook + else _grpc_client_request_hook ) return otel_grpc.aio_client_interceptors( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, - response_hook=_client_response_hook, + response_hook=_grpc_client_response_hook, ) From 7f6519f760832efbb3975eb08609471dd205aac0 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 9 Sep 2026 09:50:10 -0400 Subject: [PATCH 05/27] test(core): align test names and assertions with _grpc_* naming convention - Rename test_extract_t4_attributes to test_extract_grpc_request_attributes - Rename test_client_request_hook to test_grpc_client_request_hook - Rename test_client_response_hook to test_grpc_client_response_hook - Update interceptor hook references to _grpc_client_* hooks --- .../tests/unit/test_observability.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 5bfa72df64cd..82dd5daa76f8 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -164,8 +164,8 @@ def test_get_otel_interceptor_enabled(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider, - request_hook=_observability._client_request_hook, - response_hook=_observability._client_response_hook, + request_hook=_observability._grpc_client_request_hook, + response_hook=_observability._grpc_client_response_hook, ) result = interceptor(mock_raw_channel) @@ -255,8 +255,8 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): assert result is mock_async_interceptors mock_otel_grpc.aio_client_interceptors.assert_called_once_with( tracer_provider=mock_tracer_provider, - request_hook=_observability._client_request_hook, - response_hook=_observability._client_response_hook, + request_hook=_observability._grpc_client_request_hook, + response_hook=_observability._grpc_client_response_hook, ) @@ -331,27 +331,27 @@ def test_extract_endpoint_attributes(): ), ], ) -def test_extract_t4_attributes(req, expected_attrs): - """Proves that _extract_t4_attributes extracts all T4 gRPC attributes.""" - assert _observability._extract_t4_attributes(req) == expected_attrs +def test_extract_grpc_request_attributes(req, expected_attrs): + """Proves that _extract_grpc_request_attributes extracts all T4 gRPC attributes.""" + assert _observability._extract_grpc_request_attributes(req) == expected_attrs -def test_client_request_hook(): - """Proves that _client_request_hook attaches extracted T4 attributes to recording spans.""" +def test_grpc_client_request_hook(): + """Proves that _grpc_client_request_hook attaches extracted T4 attributes to recording spans.""" # Non-recording span should not set attributes mock_span_non_rec = mock.Mock() mock_span_non_rec.is_recording.return_value = False - _observability._client_request_hook(mock_span_non_rec, mock.Mock()) + _observability._grpc_client_request_hook(mock_span_non_rec, mock.Mock()) mock_span_non_rec.set_attribute.assert_not_called() # None span should safely return - _observability._client_request_hook(None, mock.Mock()) + _observability._grpc_client_request_hook(None, mock.Mock()) # Recording span with default hook mock_span_rec = mock.Mock() mock_span_rec.is_recording.return_value = True req = types.SimpleNamespace(name="projects/my-proj/secrets/s1", resend_count=1) - _observability._client_request_hook(mock_span_rec, req) + _observability._grpc_client_request_hook(mock_span_rec, req) mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc") mock_span_rec.set_attribute.assert_any_call( "gcp.resource.destination.id", "projects/my-proj/secrets/s1" @@ -359,7 +359,7 @@ def test_client_request_hook(): mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1) # Custom hook with endpoint attributes - endpoint_hook = _observability._make_client_request_hook( + endpoint_hook = _observability._make_grpc_client_request_hook( {"server.address": "custom.api.com", "server.port": 443} ) mock_span_custom = mock.Mock() @@ -369,21 +369,21 @@ def test_client_request_hook(): mock_span_custom.set_attribute.assert_any_call("server.port", 443) -def test_client_response_hook(): - """Proves that _client_response_hook sets rpc.response.status_code, error.type, and status.message.""" +def test_grpc_client_response_hook(): + """Proves that _grpc_client_response_hook sets rpc.response.status_code, error.type, and status.message.""" # Non-recording span should not set attributes mock_span_non_rec = mock.Mock() mock_span_non_rec.is_recording.return_value = False - _observability._client_response_hook(mock_span_non_rec, mock.Mock()) + _observability._grpc_client_response_hook(mock_span_non_rec, mock.Mock()) mock_span_non_rec.set_attribute.assert_not_called() # None span should safely return - _observability._client_response_hook(None, mock.Mock()) + _observability._grpc_client_response_hook(None, mock.Mock()) # Response with no code method defaults to OK mock_span_ok = mock.Mock() mock_span_ok.is_recording.return_value = True - _observability._client_response_hook(mock_span_ok, mock.Mock(spec=[])) + _observability._grpc_client_response_hook(mock_span_ok, mock.Mock(spec=[])) mock_span_ok.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") # Response with StatusCode object having name (e.g. OK) @@ -393,7 +393,7 @@ def test_client_response_hook(): mock_code_ok = mock.Mock() mock_code_ok.name = "OK" mock_resp_ok.code.return_value = mock_code_ok - _observability._client_response_hook(mock_span_code_obj, mock_resp_ok) + _observability._grpc_client_response_hook(mock_span_code_obj, mock_resp_ok) mock_span_code_obj.set_attribute.assert_called_once_with( "rpc.response.status_code", "OK" ) @@ -404,7 +404,7 @@ def test_client_response_hook(): mock_resp_err = mock.Mock() mock_resp_err.code.return_value = 14 mock_resp_err.details.return_value = "Service temporarily unavailable" - _observability._client_response_hook(mock_span_err, mock_resp_err) + _observability._grpc_client_response_hook(mock_span_err, mock_resp_err) mock_span_err.set_attribute.assert_any_call( "rpc.response.status_code", "UNAVAILABLE" ) @@ -418,7 +418,7 @@ def test_client_response_hook(): mock_span_exc.is_recording.return_value = True mock_resp_exc = mock.Mock() mock_resp_exc.code.side_effect = RuntimeError("Broken call") - _observability._client_response_hook(mock_span_exc, mock_resp_exc) + _observability._grpc_client_response_hook(mock_span_exc, mock_resp_exc) mock_span_exc.set_attribute.assert_called_once_with( "rpc.response.status_code", "OK" ) @@ -428,7 +428,7 @@ def test_client_response_hook(): mock_span_no_det.is_recording.return_value = True mock_resp_no_det = mock.Mock(spec=["code"]) mock_resp_no_det.code.return_value = 14 - _observability._client_response_hook(mock_span_no_det, mock_resp_no_det) + _observability._grpc_client_response_hook(mock_span_no_det, mock_resp_no_det) mock_span_no_det.set_attribute.assert_any_call( "rpc.response.status_code", "UNAVAILABLE" ) @@ -440,7 +440,7 @@ def test_client_response_hook(): mock_resp_empty_det = mock.Mock() mock_resp_empty_det.code.return_value = 14 mock_resp_empty_det.details.return_value = "" - _observability._client_response_hook(mock_span_empty_det, mock_resp_empty_det) + _observability._grpc_client_response_hook(mock_span_empty_det, mock_resp_empty_det) mock_span_empty_det.set_attribute.assert_any_call( "rpc.response.status_code", "UNAVAILABLE" ) @@ -451,7 +451,7 @@ def test_client_response_hook(): mock_resp_exc_det = mock.Mock() mock_resp_exc_det.code.return_value = 14 mock_resp_exc_det.details.side_effect = RuntimeError("Details broken") - _observability._client_response_hook(mock_span_exc_det, mock_resp_exc_det) + _observability._grpc_client_response_hook(mock_span_exc_det, mock_resp_exc_det) mock_span_exc_det.set_attribute.assert_any_call( "rpc.response.status_code", "UNAVAILABLE" ) @@ -488,7 +488,7 @@ def test_get_otel_interceptor_with_api_endpoint(monkeypatch): # Verify custom request hook was passed args, kwargs = mock_otel_grpc.client_interceptor.call_args req_hook = kwargs["request_hook"] - assert req_hook is not _observability._client_request_hook + assert req_hook is not _observability._grpc_client_request_hook # Test invoking the custom hook mock_span = mock.Mock() @@ -520,7 +520,7 @@ def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): args, kwargs = mock_otel_grpc.aio_client_interceptors.call_args req_hook = kwargs["request_hook"] - assert req_hook is not _observability._client_request_hook + assert req_hook is not _observability._grpc_client_request_hook mock_span = mock.Mock() mock_span.is_recording.return_value = True From b0de0a4ad72da5c02e6d5c5df0efe948442bb3b6 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 9 Sep 2026 20:24:40 -0400 Subject: [PATCH 06/27] feat(core): add url.domain, error attributes, and streamline T4 hooks - Add url.domain extraction from universe_domain or default to googleapis.com - Add _extract_error_attributes helper to extract gcp.errors.domain and gcp.errors.metadata. - Omit server.port when port matches scheme defaults (443 for https/grpc, 80 for http) - Remove redundant _grpc_client_response_hook and _STATUS_CODE_NAMES - Deduplicate name and parent resource lookup for gcp.resource.destination.id - Add comprehensive parametrized unit tests and update interceptor test suites --- .../google/api_core/_observability.py | 165 ++++++----- .../tests/unit/test_observability.py | 269 ++++++++++-------- 2 files changed, 229 insertions(+), 205 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index a537c2756207..33838cf19f01 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -18,6 +18,7 @@ from __future__ import annotations +import urllib.parse from typing import TYPE_CHECKING, Any, Callable, Sequence from google.api_core import _feature_gating_helpers @@ -64,51 +65,43 @@ def is_otel_capabilities_enabled( return False -_STATUS_CODE_NAMES = { - 0: "OK", - 1: "CANCELLED", - 2: "UNKNOWN", - 3: "INVALID_ARGUMENT", - 4: "DEADLINE_EXCEEDED", - 5: "NOT_FOUND", - 6: "ALREADY_EXISTS", - 7: "PERMISSION_DENIED", - 8: "RESOURCE_EXHAUSTED", - 9: "FAILED_PRECONDITION", - 10: "ABORTED", - 11: "OUT_OF_RANGE", - 12: "UNIMPLEMENTED", - 13: "INTERNAL", - 14: "UNAVAILABLE", - 15: "DATA_LOSS", - 16: "UNAUTHENTICATED", -} - - def _extract_endpoint_attributes( client_options: ClientOptions | dict[str, Any] | None = None, ) -> dict[str, Any]: - """Extracts server.address and server.port from client options if present.""" + """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. + + Returns: + dict[str, Any]: A dictionary containing url.domain and, if an api_endpoint is configured, + server.address and non-default server.port. + """ attrs: dict[str, Any] = {} endpoint = None + universe_domain = None + if isinstance(client_options, dict): endpoint = client_options.get("api_endpoint") + universe_domain = client_options.get("universe_domain") elif client_options is not None: endpoint = getattr(client_options, "api_endpoint", None) + universe_domain = getattr(client_options, "universe_domain", None) + + attrs["url.domain"] = universe_domain or "googleapis.com" if endpoint and isinstance(endpoint, str): - clean = endpoint.replace("http://", "").replace("https://", "").strip("/") - if clean: - if ":" in clean: - host, port_str = clean.split(":", 1) - attrs["server.address"] = host - try: - attrs["server.port"] = int(port_str) - except ValueError: - attrs["server.port"] = 443 - else: - attrs["server.address"] = clean - attrs["server.port"] = 443 + target = endpoint if "//" in endpoint else f"//{endpoint}" + parsed = urllib.parse.urlsplit(target) + if parsed.hostname: + attrs["server.address"] = parsed.hostname + if parsed.port: + scheme = parsed.scheme.lower() + is_default_port = (parsed.port == 443 and scheme in ("https", "")) or ( + parsed.port == 80 and scheme == "http" + ) + if not is_default_port: + attrs["server.port"] = parsed.port return attrs @@ -131,13 +124,46 @@ def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: if isinstance(resend_count, int) and resend_count > 0: attrs["gcp.grpc.resend_count"] = resend_count - name = getattr(request, "name", None) - if isinstance(name, str) and name: - attrs["gcp.resource.destination.id"] = name - else: - parent = getattr(request, "parent", None) - if isinstance(parent, str) and parent: - attrs["gcp.resource.destination.id"] = parent + resource_id = getattr(request, "name", None) or getattr(request, "parent", None) + if isinstance(resource_id, str) and resource_id: + attrs["gcp.resource.destination.id"] = resource_id + + return attrs + + +def _extract_error_attributes(exc: Any) -> dict[str, Any]: + """Extracts gcp.errors.domain, gcp.errors.metadata.*, and error.type from an exception or ErrorInfo. + + Args: + exc: An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object. + + Returns: + dict[str, Any]: Extracted error attributes. + """ + attrs: dict[str, Any] = {} + if exc is None: + return attrs + + error_info = getattr(exc, "error_info", None) + if error_info is None and hasattr(exc, "trailing_metadata"): + try: + from google.api_core import exceptions + + _, error_info = exceptions._parse_grpc_error_details(exc) + except Exception: + pass + + if error_info is not None: + domain = getattr(error_info, "domain", None) + if domain and isinstance(domain, str): + attrs["gcp.errors.domain"] = domain + reason = getattr(error_info, "reason", None) + if reason and isinstance(reason, str): + attrs["error.type"] = reason + metadata = getattr(error_info, "metadata", None) + if metadata and hasattr(metadata, "items"): + for k, v in metadata.items(): + attrs[f"gcp.errors.metadata.{k}"] = str(v) return attrs @@ -145,14 +171,22 @@ def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: def _make_grpc_client_request_hook( endpoint_attrs: dict[str, Any] | None = None, ) -> Callable[[Any, Any], None]: - """Creates an OpenTelemetry gRPC client request hook with optional endpoint attributes.""" + """Creates an OpenTelemetry gRPC client request hook with optional endpoint attributes. + + Args: + endpoint_attrs: Optional static endpoint attributes to attach to every span. + + Returns: + Callable[[Any, Any], None]: The request hook callback. + """ + static_attrs = dict(endpoint_attrs) if endpoint_attrs else {} def client_request_hook(span: Any, request: Any) -> None: if span is None or not getattr(span, "is_recording", lambda: True)(): return attrs = _extract_grpc_request_attributes(request) - if endpoint_attrs: - attrs.update(endpoint_attrs) + if static_attrs: + attrs.update(static_attrs) for key, value in attrs.items(): span.set_attribute(key, value) @@ -162,35 +196,6 @@ def client_request_hook(span: Any, request: Any) -> None: _grpc_client_request_hook = _make_grpc_client_request_hook() -def _grpc_client_response_hook(span: Any, response: Any) -> None: - """OpenTelemetry gRPC client response hook to inject response status attributes into the span.""" - if span is None or not getattr(span, "is_recording", lambda: True)(): - return - - status_str = "OK" - code_fn = getattr(response, "code", None) - if callable(code_fn): - try: - code_val = code_fn() - status_str = getattr(code_val, "name", None) or _STATUS_CODE_NAMES.get( - code_val, str(code_val) - ) - except Exception: - pass - - span.set_attribute("rpc.response.status_code", status_str) - if status_str != "OK": - span.set_attribute("error.type", status_str) - details_fn = getattr(response, "details", None) - if callable(details_fn): - try: - details = details_fn() - if details: - span.set_attribute("status.message", str(details)) - except Exception: - pass - - def _get_tracer_provider( client_options: ClientOptions | dict[str, Any] | None = None, ) -> opentelemetry.trace.TracerProvider | None: @@ -229,16 +234,11 @@ def get_otel_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] endpoint_attrs = _extract_endpoint_attributes(client_options) - request_hook = ( - _make_grpc_client_request_hook(endpoint_attrs) - if endpoint_attrs - else _grpc_client_request_hook - ) + request_hook = _make_grpc_client_request_hook(endpoint_attrs) interceptor: ClientInterceptor = otel_grpc.client_interceptor( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, - response_hook=_grpc_client_response_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -267,14 +267,9 @@ def get_otel_async_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] endpoint_attrs = _extract_endpoint_attributes(client_options) - request_hook = ( - _make_grpc_client_request_hook(endpoint_attrs) - if endpoint_attrs - else _grpc_client_request_hook - ) + request_hook = _make_grpc_client_request_hook(endpoint_attrs) return otel_grpc.aio_client_interceptors( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, - response_hook=_grpc_client_response_hook, ) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 82dd5daa76f8..4ae31e9c163e 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -164,9 +164,13 @@ def test_get_otel_interceptor_enabled(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider, - request_hook=_observability._grpc_client_request_hook, - response_hook=_observability._grpc_client_response_hook, + request_hook=mock.ANY, ) + req_hook = mock_otel_grpc.client_interceptor.call_args[1]["request_hook"] + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + req_hook(mock_span, None) + mock_span.set_attribute.assert_any_call("url.domain", "googleapis.com") result = interceptor(mock_raw_channel) assert result is mock_wrapped_channel @@ -255,38 +259,76 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): assert result is mock_async_interceptors mock_otel_grpc.aio_client_interceptors.assert_called_once_with( tracer_provider=mock_tracer_provider, - request_hook=_observability._grpc_client_request_hook, - response_hook=_observability._grpc_client_response_hook, - ) - - -def test_extract_endpoint_attributes(): - """Proves that _extract_endpoint_attributes correctly parses server.address and server.port.""" - # None or empty options - assert _observability._extract_endpoint_attributes(None) == {} - assert _observability._extract_endpoint_attributes({}) == {} - assert ( - _observability._extract_endpoint_attributes(ClientOptions(api_endpoint=None)) - == {} + request_hook=mock.ANY, ) + req_hook = mock_otel_grpc.aio_client_interceptors.call_args[1]["request_hook"] + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + req_hook(mock_span, None) + mock_span.set_attribute.assert_any_call("url.domain", "googleapis.com") - # Dict options with standard endpoint - dict_opts = {"api_endpoint": "secretmanager.googleapis.com"} - attrs = _observability._extract_endpoint_attributes(dict_opts) - assert attrs["server.address"] == "secretmanager.googleapis.com" - assert attrs["server.port"] == 443 - - # ClientOptions with custom port - custom_opts = ClientOptions(api_endpoint="https://my-custom-host.com:8443/") - attrs = _observability._extract_endpoint_attributes(custom_opts) - assert attrs["server.address"] == "my-custom-host.com" - assert attrs["server.port"] == 8443 - # Invalid port string falls back to 443 - invalid_port_opts = ClientOptions(api_endpoint="my-custom-host.com:invalid_port") - attrs = _observability._extract_endpoint_attributes(invalid_port_opts) - assert attrs["server.address"] == "my-custom-host.com" - assert attrs["server.port"] == 443 +@pytest.mark.parametrize( + "client_options,expected_attrs", + [ + (None, {"url.domain": "googleapis.com"}), + ({}, {"url.domain": "googleapis.com"}), + (ClientOptions(api_endpoint=None), {"url.domain": "googleapis.com"}), + ({"universe_domain": "myuniverse.com"}, {"url.domain": "myuniverse.com"}), + ( + ClientOptions(universe_domain="custom.domain"), + {"url.domain": "custom.domain"}, + ), + ( + {"api_endpoint": "secretmanager.googleapis.com"}, + { + "server.address": "secretmanager.googleapis.com", + "url.domain": "googleapis.com", + }, + ), + ( + {"api_endpoint": "secretmanager.googleapis.com:443"}, + { + "server.address": "secretmanager.googleapis.com", + "url.domain": "googleapis.com", + }, + ), + ( + {"api_endpoint": "https://secretmanager.googleapis.com:443"}, + { + "server.address": "secretmanager.googleapis.com", + "url.domain": "googleapis.com", + }, + ), + ( + {"api_endpoint": "http://localhost:80"}, + {"server.address": "localhost", "url.domain": "googleapis.com"}, + ), + ( + ClientOptions(api_endpoint="https://my-custom-host.com:8443/"), + { + "server.address": "my-custom-host.com", + "server.port": 8443, + "url.domain": "googleapis.com", + }, + ), + ( + ClientOptions(api_endpoint="http://[::1]:8080"), + { + "server.address": "::1", + "server.port": 8080, + "url.domain": "googleapis.com", + }, + ), + ( + ClientOptions(api_endpoint="http:///"), + {"url.domain": "googleapis.com"}, + ), + ], +) +def test_extract_endpoint_attributes(client_options, expected_attrs): + """Proves that _extract_endpoint_attributes correctly parses server.address, non-default server.port, and url.domain.""" + assert _observability._extract_endpoint_attributes(client_options) == expected_attrs @pytest.mark.parametrize( @@ -369,108 +411,90 @@ def test_grpc_client_request_hook(): mock_span_custom.set_attribute.assert_any_call("server.port", 443) -def test_grpc_client_response_hook(): - """Proves that _grpc_client_response_hook sets rpc.response.status_code, error.type, and status.message.""" - # Non-recording span should not set attributes - mock_span_non_rec = mock.Mock() - mock_span_non_rec.is_recording.return_value = False - _observability._grpc_client_response_hook(mock_span_non_rec, mock.Mock()) - mock_span_non_rec.set_attribute.assert_not_called() +def test_extract_error_attributes_none(): + """Proves that _extract_error_attributes returns an empty dict when exception is None.""" + assert _observability._extract_error_attributes(None) == {} - # None span should safely return - _observability._grpc_client_response_hook(None, mock.Mock()) - - # Response with no code method defaults to OK - mock_span_ok = mock.Mock() - mock_span_ok.is_recording.return_value = True - _observability._grpc_client_response_hook(mock_span_ok, mock.Mock(spec=[])) - mock_span_ok.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") - - # Response with StatusCode object having name (e.g. OK) - mock_span_code_obj = mock.Mock() - mock_span_code_obj.is_recording.return_value = True - mock_resp_ok = mock.Mock() - mock_code_ok = mock.Mock() - mock_code_ok.name = "OK" - mock_resp_ok.code.return_value = mock_code_ok - _observability._grpc_client_response_hook(mock_span_code_obj, mock_resp_ok) - mock_span_code_obj.set_attribute.assert_called_once_with( - "rpc.response.status_code", "OK" - ) - # Response with error status (e.g. integer 14 -> UNAVAILABLE) and details - mock_span_err = mock.Mock() - mock_span_err.is_recording.return_value = True - mock_resp_err = mock.Mock() - mock_resp_err.code.return_value = 14 - mock_resp_err.details.return_value = "Service temporarily unavailable" - _observability._grpc_client_response_hook(mock_span_err, mock_resp_err) - mock_span_err.set_attribute.assert_any_call( - "rpc.response.status_code", "UNAVAILABLE" - ) - mock_span_err.set_attribute.assert_any_call("error.type", "UNAVAILABLE") - mock_span_err.set_attribute.assert_any_call( - "status.message", "Service temporarily unavailable" +def test_extract_error_attributes_standard_exception(): + """Proves that _extract_error_attributes returns an empty dict for standard exceptions without ErrorInfo.""" + assert ( + _observability._extract_error_attributes(ValueError("unexpected error")) == {} ) - # Response where code() raises an exception is handled gracefully - mock_span_exc = mock.Mock() - mock_span_exc.is_recording.return_value = True - mock_resp_exc = mock.Mock() - mock_resp_exc.code.side_effect = RuntimeError("Broken call") - _observability._grpc_client_response_hook(mock_span_exc, mock_resp_exc) - mock_span_exc.set_attribute.assert_called_once_with( - "rpc.response.status_code", "OK" - ) - # Response with error status but no details method - mock_span_no_det = mock.Mock() - mock_span_no_det.is_recording.return_value = True - mock_resp_no_det = mock.Mock(spec=["code"]) - mock_resp_no_det.code.return_value = 14 - _observability._grpc_client_response_hook(mock_span_no_det, mock_resp_no_det) - mock_span_no_det.set_attribute.assert_any_call( - "rpc.response.status_code", "UNAVAILABLE" +def test_extract_error_attributes_with_error_info(): + """Proves that _extract_error_attributes extracts domain, error.type, and metadata from ErrorInfo.""" + error_info = types.SimpleNamespace( + domain="googleapis.com", + reason="SERVICE_DISABLED", + metadata={ + "service": "secretmanager.googleapis.com", + "consumer": "projects/123", + }, ) - mock_span_no_det.set_attribute.assert_any_call("error.type", "UNAVAILABLE") - - # Response with error status where details() returns empty/None - mock_span_empty_det = mock.Mock() - mock_span_empty_det.is_recording.return_value = True - mock_resp_empty_det = mock.Mock() - mock_resp_empty_det.code.return_value = 14 - mock_resp_empty_det.details.return_value = "" - _observability._grpc_client_response_hook(mock_span_empty_det, mock_resp_empty_det) - mock_span_empty_det.set_attribute.assert_any_call( - "rpc.response.status_code", "UNAVAILABLE" + exc = types.SimpleNamespace(error_info=error_info) + attrs = _observability._extract_error_attributes(exc) + assert attrs == { + "gcp.errors.domain": "googleapis.com", + "error.type": "SERVICE_DISABLED", + "gcp.errors.metadata.service": "secretmanager.googleapis.com", + "gcp.errors.metadata.consumer": "projects/123", + } + + +def test_extract_error_attributes_from_grpc_trailing_metadata(monkeypatch): + """Proves that _extract_error_attributes parses error_info from gRPC trailing metadata.""" + from google.api_core import exceptions + + mock_exc = mock.Mock() + mock_exc.error_info = None + mock_exc.trailing_metadata = mock.Mock() + + parsed_error_info = types.SimpleNamespace( + domain="googleapis.com", + reason="RESOURCE_EXHAUSTED", + metadata={"quota_limit": "100"}, ) - # Response with error status where details() raises an exception - mock_span_exc_det = mock.Mock() - mock_span_exc_det.is_recording.return_value = True - mock_resp_exc_det = mock.Mock() - mock_resp_exc_det.code.return_value = 14 - mock_resp_exc_det.details.side_effect = RuntimeError("Details broken") - _observability._grpc_client_response_hook(mock_span_exc_det, mock_resp_exc_det) - mock_span_exc_det.set_attribute.assert_any_call( - "rpc.response.status_code", "UNAVAILABLE" + monkeypatch.setattr( + exceptions, + "_parse_grpc_error_details", + mock.Mock(return_value=(None, parsed_error_info)), ) + attrs = _observability._extract_error_attributes(mock_exc) + assert attrs == { + "gcp.errors.domain": "googleapis.com", + "error.type": "RESOURCE_EXHAUSTED", + "gcp.errors.metadata.quota_limit": "100", + } -def test_extract_endpoint_attributes_empty_clean(): - """Proves that endpoint consisting only of slashes/protocol results in empty attrs.""" - assert ( - _observability._extract_endpoint_attributes( - ClientOptions(api_endpoint="http:///") - ) - == {} + +def test_extract_error_attributes_trailing_metadata_failure(monkeypatch): + """Proves that _extract_error_attributes safely handles exceptions during trailing metadata parsing.""" + from google.api_core import exceptions + + mock_exc = mock.Mock() + mock_exc.error_info = None + mock_exc.trailing_metadata = mock.Mock() + + monkeypatch.setattr( + exceptions, + "_parse_grpc_error_details", + mock.Mock(side_effect=RuntimeError("Parse failed")), ) + assert _observability._extract_error_attributes(mock_exc) == {} + def test_get_otel_interceptor_with_api_endpoint(monkeypatch): - """Proves that get_otel_interceptor injects server.address and server.port when api_endpoint is set.""" + """Proves that get_otel_interceptor injects server.address, server.port, and url.domain when api_endpoint is set.""" monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") - options = ClientOptions(api_endpoint="secretmanager.googleapis.com:443") + options = ClientOptions( + api_endpoint="secretmanager.googleapis.com:8443", + universe_domain="custom-domain.com", + ) mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc @@ -497,13 +521,17 @@ def test_get_otel_interceptor_with_api_endpoint(monkeypatch): mock_span.set_attribute.assert_any_call( "server.address", "secretmanager.googleapis.com" ) - mock_span.set_attribute.assert_any_call("server.port", 443) + mock_span.set_attribute.assert_any_call("server.port", 8443) + mock_span.set_attribute.assert_any_call("url.domain", "custom-domain.com") def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): - """Proves that get_otel_async_interceptor injects server.address and server.port when api_endpoint is set.""" + """Proves that get_otel_async_interceptor injects server.address, server.port, and url.domain when api_endpoint is set.""" monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") - options = ClientOptions(api_endpoint="secretmanager.googleapis.com:8443") + options = ClientOptions( + api_endpoint="secretmanager.googleapis.com:8443", + universe_domain="custom-domain.com", + ) mock_otel = mock.Mock() mock_otel_grpc = mock_otel.instrumentation.grpc @@ -529,3 +557,4 @@ def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): "server.address", "secretmanager.googleapis.com" ) mock_span.set_attribute.assert_any_call("server.port", 8443) + mock_span.set_attribute.assert_any_call("url.domain", "custom-domain.com") From ac095c5103c0dd8da262b7a351fed50cfcee80b7 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 05:42:47 -0400 Subject: [PATCH 07/27] feat(core): normalize gRPC span names and eliminate duplicate rpc.system attribute - Strip leading slash from gRPC attempt span names via span.update_name - Set rpc.method to the fully qualified method name per PRD specification - Retain rpc.system.name: 'grpc' and remove legacy rpc.system attribute to avoid duplication - Update unit tests to verify span name normalization and attribute deduplication --- .../google/api_core/_observability.py | 19 +++++++++++++ .../tests/unit/test_observability.py | 27 ++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 33838cf19f01..5e8f574ba06f 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -184,7 +184,26 @@ def _make_grpc_client_request_hook( def client_request_hook(span: Any, request: Any) -> None: if span is None or not getattr(span, "is_recording", lambda: True)(): return + + # Upstream opentelemetry-instrumentation-grpc names spans with a leading slash + # (e.g. "/package.Service/Method") and sets only the short name on rpc.method. + # Normalize span.name and rpc.method to the fully-qualified name without leading slash. + span_name = getattr(span, "name", None) + clean_method_name = None + if isinstance(span_name, str) and span_name.startswith("/"): + clean_method_name = span_name.lstrip("/") + if hasattr(span, "update_name"): + span.update_name(clean_method_name) + + # Remove duplicate legacy rpc.system attribute set by stock instrumentation + # in favor of modern rpc.system.name ("grpc") per PRD changelog. + span_attributes = getattr(span, "_attributes", None) + if hasattr(span_attributes, "pop"): + span_attributes.pop("rpc.system", None) + attrs = _extract_grpc_request_attributes(request) + if clean_method_name: + attrs["rpc.method"] = clean_method_name if static_attrs: attrs.update(static_attrs) for key, value in attrs.items(): diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 4ae31e9c163e..982797aed4ef 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -379,7 +379,9 @@ def test_extract_grpc_request_attributes(req, expected_attrs): def test_grpc_client_request_hook(): - """Proves that _grpc_client_request_hook attaches extracted T4 attributes to recording spans.""" + """Proves that _grpc_client_request_hook attaches extracted T4 attributes to recording spans, + normalizes span names, sets fully qualified rpc.method, and removes legacy rpc.system. + """ # Non-recording span should not set attributes mock_span_non_rec = mock.Mock() mock_span_non_rec.is_recording.return_value = False @@ -389,26 +391,45 @@ def test_grpc_client_request_hook(): # None span should safely return _observability._grpc_client_request_hook(None, mock.Mock()) - # Recording span with default hook + # Recording span with default hook, leading slash in span.name, and legacy rpc.system mock_span_rec = mock.Mock() mock_span_rec.is_recording.return_value = True + mock_span_rec.name = ( + "/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + mock_span_rec._attributes = {"rpc.system": "grpc"} req = types.SimpleNamespace(name="projects/my-proj/secrets/s1", resend_count=1) + _observability._grpc_client_request_hook(mock_span_rec, req) + + # Verify span name normalized and rpc.method set to fully qualified name + mock_span_rec.update_name.assert_called_once_with( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + mock_span_rec.set_attribute.assert_any_call( + "rpc.method", "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + + # Verify rpc.system.name set and legacy rpc.system popped mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc") + assert "rpc.system" not in mock_span_rec._attributes + mock_span_rec.set_attribute.assert_any_call( "gcp.resource.destination.id", "projects/my-proj/secrets/s1" ) mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1) - # Custom hook with endpoint attributes + # Custom hook with endpoint attributes and already-clean span name endpoint_hook = _observability._make_grpc_client_request_hook( {"server.address": "custom.api.com", "server.port": 443} ) mock_span_custom = mock.Mock() mock_span_custom.is_recording.return_value = True + mock_span_custom.name = "already_clean_name" endpoint_hook(mock_span_custom, req) mock_span_custom.set_attribute.assert_any_call("server.address", "custom.api.com") mock_span_custom.set_attribute.assert_any_call("server.port", 443) + mock_span_custom.update_name.assert_not_called() def test_extract_error_attributes_none(): From ef7d77d03313d1c159bdb41cde3abf0744708ba9 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 05:49:18 -0400 Subject: [PATCH 08/27] refactor(core): remove deferred gcp.resource.destination.id attribute - Remove gcp.resource.destination.id extraction from _extract_grpc_request_attributes - Update unit tests to reflect attribute removal per July Strategy Update --- .../google/api_core/_observability.py | 4 ---- .../tests/unit/test_observability.py | 23 ++----------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 5e8f574ba06f..b8daf298111e 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -124,10 +124,6 @@ def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: if isinstance(resend_count, int) and resend_count > 0: attrs["gcp.grpc.resend_count"] = resend_count - resource_id = getattr(request, "name", None) or getattr(request, "parent", None) - if isinstance(resource_id, str) and resource_id: - attrs["gcp.resource.destination.id"] = resource_id - return attrs diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 982797aed4ef..7adc333fa8e5 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -338,32 +338,16 @@ def test_extract_endpoint_attributes(client_options, expected_attrs): (types.SimpleNamespace(), {"rpc.system.name": "grpc"}), ( types.SimpleNamespace(name="projects/p1/secrets/s1"), - { - "rpc.system.name": "grpc", - "gcp.resource.destination.id": "projects/p1/secrets/s1", - }, + {"rpc.system.name": "grpc"}, ), ( types.SimpleNamespace(parent="projects/parent-p1"), - { - "rpc.system.name": "grpc", - "gcp.resource.destination.id": "projects/parent-p1", - }, - ), - ( - types.SimpleNamespace( - name="projects/p1/secrets/s1", parent="projects/parent-p1" - ), - { - "rpc.system.name": "grpc", - "gcp.resource.destination.id": "projects/p1/secrets/s1", - }, + {"rpc.system.name": "grpc"}, ), ( types.SimpleNamespace(name="projects/p1/secrets/s1", resend_count=2), { "rpc.system.name": "grpc", - "gcp.resource.destination.id": "projects/p1/secrets/s1", "gcp.grpc.resend_count": 2, }, ), @@ -414,9 +398,6 @@ def test_grpc_client_request_hook(): mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc") assert "rpc.system" not in mock_span_rec._attributes - mock_span_rec.set_attribute.assert_any_call( - "gcp.resource.destination.id", "projects/my-proj/secrets/s1" - ) mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1) # Custom hook with endpoint attributes and already-clean span name From aa2beadada6701847a1e5e30615d2116bb0262dd Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 06:33:05 -0400 Subject: [PATCH 09/27] feat(core): record rpc.response.status_code on wire attempt spans --- .../google/api_core/_observability.py | 39 +++++++++++++++++++ .../tests/unit/test_observability.py | 32 +++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index b8daf298111e..98ca898c4e7b 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -211,6 +211,43 @@ def client_request_hook(span: Any, request: Any) -> None: _grpc_client_request_hook = _make_grpc_client_request_hook() +def _grpc_client_response_hook(span: Any, response: Any) -> None: + """OpenTelemetry gRPC client response hook to record response status code. + + Args: + span: The OpenTelemetry span. + response: The gRPC response object or details. + """ + if span is None or not hasattr(span, "set_attribute"): + return + + status = getattr(span, "status", None) + status_code = getattr(status, "status_code", None) + try: + from opentelemetry.trace.status import StatusCode + + if status_code == StatusCode.ERROR: + span_attrs = ( + getattr(span, "attributes", None) + or getattr(span, "_attributes", None) + or {} + ) + grpc_code = span_attrs.get("rpc.grpc.status_code") + if grpc_code is not None: + from google.api_core import exceptions + + if grpc_code in exceptions._INT_TO_GRPC_CODE: + span.set_attribute( + "rpc.response.status_code", + exceptions._INT_TO_GRPC_CODE[grpc_code].name, + ) + return + except Exception: + pass + + span.set_attribute("rpc.response.status_code", "OK") + + def _get_tracer_provider( client_options: ClientOptions | dict[str, Any] | None = None, ) -> opentelemetry.trace.TracerProvider | None: @@ -254,6 +291,7 @@ def get_otel_interceptor( interceptor: ClientInterceptor = otel_grpc.client_interceptor( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, + response_hook=_grpc_client_response_hook, ) def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: @@ -287,4 +325,5 @@ def get_otel_async_interceptor( return otel_grpc.aio_client_interceptors( tracer_provider=_get_tracer_provider(client_options), request_hook=request_hook, + response_hook=_grpc_client_response_hook, ) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 7adc333fa8e5..e25139ce0a39 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -165,6 +165,7 @@ def test_get_otel_interceptor_enabled(monkeypatch): mock_otel_grpc.client_interceptor.assert_called_once_with( tracer_provider=mock_tracer_provider, request_hook=mock.ANY, + response_hook=_observability._grpc_client_response_hook, ) req_hook = mock_otel_grpc.client_interceptor.call_args[1]["request_hook"] mock_span = mock.Mock() @@ -260,7 +261,9 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): mock_otel_grpc.aio_client_interceptors.assert_called_once_with( tracer_provider=mock_tracer_provider, request_hook=mock.ANY, + response_hook=_observability._grpc_client_response_hook, ) + req_hook = mock_otel_grpc.aio_client_interceptors.call_args[1]["request_hook"] mock_span = mock.Mock() mock_span.is_recording.return_value = True @@ -515,6 +518,7 @@ def test_get_otel_interceptor_with_api_endpoint(monkeypatch): args, kwargs = mock_otel_grpc.client_interceptor.call_args req_hook = kwargs["request_hook"] assert req_hook is not _observability._grpc_client_request_hook + assert kwargs["response_hook"] is _observability._grpc_client_response_hook # Test invoking the custom hook mock_span = mock.Mock() @@ -551,6 +555,7 @@ def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): args, kwargs = mock_otel_grpc.aio_client_interceptors.call_args req_hook = kwargs["request_hook"] assert req_hook is not _observability._grpc_client_request_hook + assert kwargs["response_hook"] is _observability._grpc_client_response_hook mock_span = mock.Mock() mock_span.is_recording.return_value = True @@ -560,3 +565,30 @@ def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): ) mock_span.set_attribute.assert_any_call("server.port", 8443) mock_span.set_attribute.assert_any_call("url.domain", "custom-domain.com") + + +def test_grpc_client_response_hook_success(): + """Proves that _grpc_client_response_hook sets rpc.response.status_code to 'OK' on success.""" + mock_span = mock.Mock() + _observability._grpc_client_response_hook(mock_span, mock.Mock()) + mock_span.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") + + +def test_grpc_client_response_hook_error_mapped(): + """Proves that _grpc_client_response_hook maps status code when span has error status.""" + from opentelemetry.trace.status import StatusCode + + mock_span = mock.Mock() + mock_span.status.status_code = StatusCode.ERROR + mock_span.attributes = {"rpc.grpc.status_code": 5} + + _observability._grpc_client_response_hook(mock_span, None) + mock_span.set_attribute.assert_called_once_with( + "rpc.response.status_code", "NOT_FOUND" + ) + + +def test_grpc_client_response_hook_none_or_missing_set_attribute(): + """Proves that _grpc_client_response_hook handles None or invalid span gracefully.""" + _observability._grpc_client_response_hook(None, mock.Mock()) + _observability._grpc_client_response_hook(object(), mock.Mock()) From 894b3808548c2c7314eaf40e0bf7d2db30d78b0b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 07:48:11 -0400 Subject: [PATCH 10/27] refactor(core): remove duplicate error attribute extraction in favor of method spans --- .../google/api_core/_observability.py | 37 --------- .../tests/unit/test_observability.py | 77 ------------------- 2 files changed, 114 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 98ca898c4e7b..4ad2b60b6e1e 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -127,43 +127,6 @@ def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: return attrs -def _extract_error_attributes(exc: Any) -> dict[str, Any]: - """Extracts gcp.errors.domain, gcp.errors.metadata.*, and error.type from an exception or ErrorInfo. - - Args: - exc: An exception (such as GoogleAPICallError or grpc.RpcError) or ErrorInfo object. - - Returns: - dict[str, Any]: Extracted error attributes. - """ - attrs: dict[str, Any] = {} - if exc is None: - return attrs - - error_info = getattr(exc, "error_info", None) - if error_info is None and hasattr(exc, "trailing_metadata"): - try: - from google.api_core import exceptions - - _, error_info = exceptions._parse_grpc_error_details(exc) - except Exception: - pass - - if error_info is not None: - domain = getattr(error_info, "domain", None) - if domain and isinstance(domain, str): - attrs["gcp.errors.domain"] = domain - reason = getattr(error_info, "reason", None) - if reason and isinstance(reason, str): - attrs["error.type"] = reason - metadata = getattr(error_info, "metadata", None) - if metadata and hasattr(metadata, "items"): - for k, v in metadata.items(): - attrs[f"gcp.errors.metadata.{k}"] = str(v) - - return attrs - - def _make_grpc_client_request_hook( endpoint_attrs: dict[str, Any] | None = None, ) -> Callable[[Any, Any], None]: diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index e25139ce0a39..3e720535f4bd 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -416,83 +416,6 @@ def test_grpc_client_request_hook(): mock_span_custom.update_name.assert_not_called() -def test_extract_error_attributes_none(): - """Proves that _extract_error_attributes returns an empty dict when exception is None.""" - assert _observability._extract_error_attributes(None) == {} - - -def test_extract_error_attributes_standard_exception(): - """Proves that _extract_error_attributes returns an empty dict for standard exceptions without ErrorInfo.""" - assert ( - _observability._extract_error_attributes(ValueError("unexpected error")) == {} - ) - - -def test_extract_error_attributes_with_error_info(): - """Proves that _extract_error_attributes extracts domain, error.type, and metadata from ErrorInfo.""" - error_info = types.SimpleNamespace( - domain="googleapis.com", - reason="SERVICE_DISABLED", - metadata={ - "service": "secretmanager.googleapis.com", - "consumer": "projects/123", - }, - ) - exc = types.SimpleNamespace(error_info=error_info) - attrs = _observability._extract_error_attributes(exc) - assert attrs == { - "gcp.errors.domain": "googleapis.com", - "error.type": "SERVICE_DISABLED", - "gcp.errors.metadata.service": "secretmanager.googleapis.com", - "gcp.errors.metadata.consumer": "projects/123", - } - - -def test_extract_error_attributes_from_grpc_trailing_metadata(monkeypatch): - """Proves that _extract_error_attributes parses error_info from gRPC trailing metadata.""" - from google.api_core import exceptions - - mock_exc = mock.Mock() - mock_exc.error_info = None - mock_exc.trailing_metadata = mock.Mock() - - parsed_error_info = types.SimpleNamespace( - domain="googleapis.com", - reason="RESOURCE_EXHAUSTED", - metadata={"quota_limit": "100"}, - ) - - monkeypatch.setattr( - exceptions, - "_parse_grpc_error_details", - mock.Mock(return_value=(None, parsed_error_info)), - ) - - attrs = _observability._extract_error_attributes(mock_exc) - assert attrs == { - "gcp.errors.domain": "googleapis.com", - "error.type": "RESOURCE_EXHAUSTED", - "gcp.errors.metadata.quota_limit": "100", - } - - -def test_extract_error_attributes_trailing_metadata_failure(monkeypatch): - """Proves that _extract_error_attributes safely handles exceptions during trailing metadata parsing.""" - from google.api_core import exceptions - - mock_exc = mock.Mock() - mock_exc.error_info = None - mock_exc.trailing_metadata = mock.Mock() - - monkeypatch.setattr( - exceptions, - "_parse_grpc_error_details", - mock.Mock(side_effect=RuntimeError("Parse failed")), - ) - - assert _observability._extract_error_attributes(mock_exc) == {} - - def test_get_otel_interceptor_with_api_endpoint(monkeypatch): """Proves that get_otel_interceptor injects server.address, server.port, and url.domain when api_endpoint is set.""" monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") From 1c1b9afcd515a565a71dc6d227c01fc03b5d98e1 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 10:29:31 -0400 Subject: [PATCH 11/27] fix(observability): resolve mypy union-attr error and support environments without grpc --- .../google/api_core/_observability.py | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 4ad2b60b6e1e..a14e2f43ddc7 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -157,8 +157,10 @@ def client_request_hook(span: Any, request: Any) -> None: # Remove duplicate legacy rpc.system attribute set by stock instrumentation # in favor of modern rpc.system.name ("grpc") per PRD changelog. span_attributes = getattr(span, "_attributes", None) - if hasattr(span_attributes, "pop"): - span_attributes.pop("rpc.system", None) + if span_attributes is not None: + pop_fn = getattr(span_attributes, "pop", None) + if callable(pop_fn): + pop_fn("rpc.system", None) attrs = _extract_grpc_request_attributes(request) if clean_method_name: @@ -173,6 +175,29 @@ def client_request_hook(span: Any, request: Any) -> None: _grpc_client_request_hook = _make_grpc_client_request_hook() +# Mapping of standard gRPC integer status codes to their canonical status name strings. +# Used when stock gRPC wire spans encounter errors, guaranteeing mapping even in environments +# where the optional `grpc` package is not installed (e.g. REST-only environments). +_GRPC_INT_STATUS_CODE_TO_NAME = { + 0: "OK", + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", +} + def _grpc_client_response_hook(span: Any, response: Any) -> None: """OpenTelemetry gRPC client response hook to record response status code. @@ -199,12 +224,16 @@ def _grpc_client_response_hook(span: Any, response: Any) -> None: if grpc_code is not None: from google.api_core import exceptions + name = None if grpc_code in exceptions._INT_TO_GRPC_CODE: - span.set_attribute( - "rpc.response.status_code", - exceptions._INT_TO_GRPC_CODE[grpc_code].name, - ) + name = exceptions._INT_TO_GRPC_CODE[grpc_code].name + elif grpc_code in _GRPC_INT_STATUS_CODE_TO_NAME: + name = _GRPC_INT_STATUS_CODE_TO_NAME[grpc_code] + if name: + span.set_attribute("rpc.response.status_code", name) return + span.set_attribute("rpc.response.status_code", "ERROR") + return except Exception: pass From 81b686ce1ab86c4fa7783618e107ba9e471e849f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 10:50:35 -0400 Subject: [PATCH 12/27] refactor(observability): simplify response hook to record OK on successful RPCs --- .../google/api_core/_observability.py | 60 ++----------------- .../tests/unit/test_observability.py | 14 ----- 2 files changed, 5 insertions(+), 69 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index a14e2f43ddc7..07cc352cd31c 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -175,69 +175,19 @@ def client_request_hook(span: Any, request: Any) -> None: _grpc_client_request_hook = _make_grpc_client_request_hook() -# Mapping of standard gRPC integer status codes to their canonical status name strings. -# Used when stock gRPC wire spans encounter errors, guaranteeing mapping even in environments -# where the optional `grpc` package is not installed (e.g. REST-only environments). -_GRPC_INT_STATUS_CODE_TO_NAME = { - 0: "OK", - 1: "CANCELLED", - 2: "UNKNOWN", - 3: "INVALID_ARGUMENT", - 4: "DEADLINE_EXCEEDED", - 5: "NOT_FOUND", - 6: "ALREADY_EXISTS", - 7: "PERMISSION_DENIED", - 8: "RESOURCE_EXHAUSTED", - 9: "FAILED_PRECONDITION", - 10: "ABORTED", - 11: "OUT_OF_RANGE", - 12: "UNIMPLEMENTED", - 13: "INTERNAL", - 14: "UNAVAILABLE", - 15: "DATA_LOSS", - 16: "UNAUTHENTICATED", -} - def _grpc_client_response_hook(span: Any, response: Any) -> None: """OpenTelemetry gRPC client response hook to record response status code. + Note: Upstream OpenTelemetry gRPC instrumentation only invokes this response_hook + on successful RPC invocations. Failed RPCs raise an exception before this hook is reached. + Args: span: The OpenTelemetry span. response: The gRPC response object or details. """ - if span is None or not hasattr(span, "set_attribute"): - return - - status = getattr(span, "status", None) - status_code = getattr(status, "status_code", None) - try: - from opentelemetry.trace.status import StatusCode - - if status_code == StatusCode.ERROR: - span_attrs = ( - getattr(span, "attributes", None) - or getattr(span, "_attributes", None) - or {} - ) - grpc_code = span_attrs.get("rpc.grpc.status_code") - if grpc_code is not None: - from google.api_core import exceptions - - name = None - if grpc_code in exceptions._INT_TO_GRPC_CODE: - name = exceptions._INT_TO_GRPC_CODE[grpc_code].name - elif grpc_code in _GRPC_INT_STATUS_CODE_TO_NAME: - name = _GRPC_INT_STATUS_CODE_TO_NAME[grpc_code] - if name: - span.set_attribute("rpc.response.status_code", name) - return - span.set_attribute("rpc.response.status_code", "ERROR") - return - except Exception: - pass - - span.set_attribute("rpc.response.status_code", "OK") + if span is not None and hasattr(span, "set_attribute"): + span.set_attribute("rpc.response.status_code", "OK") def _get_tracer_provider( diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 3e720535f4bd..635505dea8c3 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -497,20 +497,6 @@ def test_grpc_client_response_hook_success(): mock_span.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") -def test_grpc_client_response_hook_error_mapped(): - """Proves that _grpc_client_response_hook maps status code when span has error status.""" - from opentelemetry.trace.status import StatusCode - - mock_span = mock.Mock() - mock_span.status.status_code = StatusCode.ERROR - mock_span.attributes = {"rpc.grpc.status_code": 5} - - _observability._grpc_client_response_hook(mock_span, None) - mock_span.set_attribute.assert_called_once_with( - "rpc.response.status_code", "NOT_FOUND" - ) - - def test_grpc_client_response_hook_none_or_missing_set_attribute(): """Proves that _grpc_client_response_hook handles None or invalid span gracefully.""" _observability._grpc_client_response_hook(None, mock.Mock()) From 324b866237447f429520f984de401ca69d06695f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 10 Sep 2026 13:40:39 -0400 Subject: [PATCH 13/27] test(observability): cover request hook span edge cases for 100% branch coverage --- .../tests/unit/test_observability.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 635505dea8c3..52554beba345 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -416,6 +416,40 @@ def test_grpc_client_request_hook(): mock_span_custom.update_name.assert_not_called() +def test_grpc_client_request_hook_span_edge_cases(): + """Proves that _grpc_client_request_hook handles spans lacking update_name, + spans with None _attributes, and spans with un-poppable _attributes gracefully. + """ + # 1. Leading slash in span.name but span lacks update_name (exercises 154->159) + mock_span_no_update = mock.Mock( + spec=["is_recording", "name", "_attributes", "set_attribute"] + ) + mock_span_no_update.is_recording.return_value = True + mock_span_no_update.name = "/package.Service/Method" + mock_span_no_update._attributes = {"rpc.system": "grpc"} + _observability._grpc_client_request_hook(mock_span_no_update, None) + mock_span_no_update.set_attribute.assert_any_call( + "rpc.method", "package.Service/Method" + ) + + # 2. Span with None _attributes (exercises 160->165) + mock_span_no_attrs = mock.Mock(spec=["is_recording", "name", "set_attribute"]) + mock_span_no_attrs.is_recording.return_value = True + mock_span_no_attrs.name = "clean_name" + _observability._grpc_client_request_hook(mock_span_no_attrs, None) + mock_span_no_attrs.set_attribute.assert_any_call("rpc.system.name", "grpc") + + # 3. Span with non-dict / un-poppable _attributes (exercises 162->165) + mock_span_unpoppable = mock.Mock( + spec=["is_recording", "name", "_attributes", "set_attribute"] + ) + mock_span_unpoppable.is_recording.return_value = True + mock_span_unpoppable.name = "clean_name" + mock_span_unpoppable._attributes = object() + _observability._grpc_client_request_hook(mock_span_unpoppable, None) + mock_span_unpoppable.set_attribute.assert_any_call("rpc.system.name", "grpc") + + def test_get_otel_interceptor_with_api_endpoint(monkeypatch): """Proves that get_otel_interceptor injects server.address, server.port, and url.domain when api_endpoint is set.""" monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") From abeaf048020eb5ac728521e9803d47b7ae122c4d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 08:07:42 -0400 Subject: [PATCH 14/27] fix(observability): safely handle invalid port in endpoint attributes --- .../google/api_core/_observability.py | 17 +++++++++++------ .../tests/unit/test_observability.py | 4 ++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 07cc352cd31c..5bd28af8cb49 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -93,15 +93,20 @@ def _extract_endpoint_attributes( if endpoint and isinstance(endpoint, str): target = endpoint if "//" in endpoint else f"//{endpoint}" parsed = urllib.parse.urlsplit(target) - if parsed.hostname: - attrs["server.address"] = parsed.hostname - if parsed.port: + port = None + try: + if parsed.hostname: + attrs["server.address"] = parsed.hostname + port = parsed.port + except ValueError: + pass + if port: scheme = parsed.scheme.lower() - is_default_port = (parsed.port == 443 and scheme in ("https", "")) or ( - parsed.port == 80 and scheme == "http" + is_default_port = (port == 443 and scheme in ("https", "")) or ( + port == 80 and scheme == "http" ) if not is_default_port: - attrs["server.port"] = parsed.port + attrs["server.port"] = port return attrs diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 52554beba345..96efabf3611f 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -327,6 +327,10 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): ClientOptions(api_endpoint="http:///"), {"url.domain": "googleapis.com"}, ), + ( + ClientOptions(api_endpoint="example.com:not_a_port"), + {"server.address": "example.com", "url.domain": "googleapis.com"}, + ), ], ) def test_extract_endpoint_attributes(client_options, expected_attrs): From 99a4d3d874258997880112098838e1b7687353c7 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 10:18:39 -0400 Subject: [PATCH 15/27] fix(observability): ensure response hook only records OK on successful calls --- .../google/api_core/_observability.py | 20 +++++++++---- .../tests/unit/test_observability.py | 30 ++++++++++++++++--- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 5bd28af8cb49..f614822eed10 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -184,15 +184,25 @@ def client_request_hook(span: Any, request: Any) -> None: def _grpc_client_response_hook(span: Any, response: Any) -> None: """OpenTelemetry gRPC client response hook to record response status code. - Note: Upstream OpenTelemetry gRPC instrumentation only invokes this response_hook - on successful RPC invocations. Failed RPCs raise an exception before this hook is reached. - Args: span: The OpenTelemetry span. response: The gRPC response object or details. """ - if span is not None and hasattr(span, "set_attribute"): - span.set_attribute("rpc.response.status_code", "OK") + if not span.is_recording(): + return + + # Verify the RPC succeeded before recording the OK response status. + # Upstream async instrumentation invokes this hook on both successes + # and failures, so check whether an error status was already recorded. + status = getattr(span, "status", None) + status_code = getattr(status, "status_code", None) + if ( + getattr(status_code, "name", None) == "ERROR" + or getattr(status_code, "value", None) == 2 + ): + return + + span.set_attribute("rpc.response.status_code", "OK") def _get_tracer_provider( diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 96efabf3611f..4b82cb43fce9 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -531,11 +531,33 @@ def test_get_otel_async_interceptor_with_api_endpoint(monkeypatch): def test_grpc_client_response_hook_success(): """Proves that _grpc_client_response_hook sets rpc.response.status_code to 'OK' on success.""" mock_span = mock.Mock() + mock_span.is_recording.return_value = True _observability._grpc_client_response_hook(mock_span, mock.Mock()) mock_span.set_attribute.assert_called_once_with("rpc.response.status_code", "OK") -def test_grpc_client_response_hook_none_or_missing_set_attribute(): - """Proves that _grpc_client_response_hook handles None or invalid span gracefully.""" - _observability._grpc_client_response_hook(None, mock.Mock()) - _observability._grpc_client_response_hook(object(), mock.Mock()) +def test_grpc_client_response_hook_not_recording(): + """Proves that _grpc_client_response_hook skips non-recording spans.""" + mock_span = mock.Mock() + mock_span.is_recording.return_value = False + _observability._grpc_client_response_hook(mock_span, mock.Mock()) + mock_span.set_attribute.assert_not_called() + + +def test_grpc_client_response_hook_error_status(): + """Proves that _grpc_client_response_hook skips spans marked with ERROR status.""" + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + mock_span.status.status_code.name = "ERROR" + _observability._grpc_client_response_hook(mock_span, mock.Mock()) + mock_span.set_attribute.assert_not_called() + + +def test_grpc_client_response_hook_error_status_value(): + """Proves that _grpc_client_response_hook skips spans with StatusCode.ERROR value (2).""" + mock_span = mock.Mock() + mock_span.is_recording.return_value = True + mock_span.status.status_code.name = "UNKNOWN" + mock_span.status.status_code.value = 2 + _observability._grpc_client_response_hook(mock_span, mock.Mock()) + mock_span.set_attribute.assert_not_called() From 4b82c9c37db55c10d9ad0e4f9c00487ec9c01b62 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 19:54:11 -0400 Subject: [PATCH 16/27] refactor(observability): address review feedback on method name, url parsing, and attribute handling --- .../google/api_core/_observability.py | 74 +++++------- .../tests/unit/test_observability.py | 105 +++++++----------- 2 files changed, 73 insertions(+), 106 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index f614822eed10..5e70c95d85b6 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -92,15 +92,19 @@ def _extract_endpoint_attributes( if endpoint and isinstance(endpoint, str): target = endpoint if "//" in endpoint else f"//{endpoint}" - parsed = urllib.parse.urlsplit(target) + parsed = None + hostname = None port = None try: - if parsed.hostname: - attrs["server.address"] = parsed.hostname + parsed = urllib.parse.urlsplit(target) + hostname = parsed.hostname port = parsed.port except ValueError: pass - if port: + + if hostname: + attrs["server.address"] = hostname + if port and parsed: scheme = parsed.scheme.lower() is_default_port = (port == 443 and scheme in ("https", "")) or ( port == 80 and scheme == "http" @@ -110,28 +114,6 @@ def _extract_endpoint_attributes( return attrs -def _extract_grpc_request_attributes(request: Any) -> dict[str, Any]: - """Extracts Google Cloud T4 semantic and resource attributes from a gRPC request object. - - Args: - request: The gRPC request object. - - Returns: - dict[str, Any]: A dictionary of semantic attributes. - """ - attrs: dict[str, Any] = { - "rpc.system.name": "grpc", - } - if request is None: - return attrs - - resend_count = getattr(request, "resend_count", None) - if isinstance(resend_count, int) and resend_count > 0: - attrs["gcp.grpc.resend_count"] = resend_count - - return attrs - - def _make_grpc_client_request_hook( endpoint_attrs: dict[str, Any] | None = None, ) -> Callable[[Any, Any], None]: @@ -149,27 +131,19 @@ def client_request_hook(span: Any, request: Any) -> None: if span is None or not getattr(span, "is_recording", lambda: True)(): return - # Upstream opentelemetry-instrumentation-grpc names spans with a leading slash - # (e.g. "/package.Service/Method") and sets only the short name on rpc.method. - # Normalize span.name and rpc.method to the fully-qualified name without leading slash. + # Upstream opentelemetry-instrumentation-grpc may format span names with a + # leading slash (e.g. "/package.Service/Method"). Normalize the span name + # and ensure rpc.method is always captured as the clean, fully-qualified name. span_name = getattr(span, "name", None) - clean_method_name = None - if isinstance(span_name, str) and span_name.startswith("/"): + if isinstance(span_name, str) and span_name: clean_method_name = span_name.lstrip("/") - if hasattr(span, "update_name"): + if span_name.startswith("/") and hasattr(span, "update_name"): span.update_name(clean_method_name) + span.set_attribute("rpc.method", clean_method_name) - # Remove duplicate legacy rpc.system attribute set by stock instrumentation - # in favor of modern rpc.system.name ("grpc") per PRD changelog. - span_attributes = getattr(span, "_attributes", None) - if span_attributes is not None: - pop_fn = getattr(span_attributes, "pop", None) - if callable(pop_fn): - pop_fn("rpc.system", None) - - attrs = _extract_grpc_request_attributes(request) - if clean_method_name: - attrs["rpc.method"] = clean_method_name + attrs: dict[str, Any] = { + "rpc.system.name": "grpc", + } if static_attrs: attrs.update(static_attrs) for key, value in attrs.items(): @@ -182,7 +156,19 @@ def client_request_hook(span: Any, request: Any) -> None: def _grpc_client_response_hook(span: Any, response: Any) -> None: - """OpenTelemetry gRPC client response hook to record response status code. + """OpenTelemetry gRPC client response hook to record successful response status. + + Upstream ``opentelemetry-instrumentation-grpc`` sets the integer status code + ``rpc.grpc.status_code`` (e.g. 0), but does not record the modern string status + ``rpc.response.status_code`` (e.g. "OK") required by Cloud Trace and current + OpenTelemetry semantic conventions. + + This hook enriches successful RPC attempt spans with ``rpc.response.status_code = "OK"``. + Errors and non-OK statuses are handled at the Tier 3 method span layer or upstream. + + Note: + If upstream ``opentelemetry-instrumentation-grpc`` adds native support for + modern ``rpc.response.status_code`` in future releases, this hook can be retired. Args: span: The OpenTelemetry span. diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 4b82cb43fce9..4d7a0d283fd1 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -13,7 +13,6 @@ # limitations under the License. import sys -import types from unittest import mock import pytest @@ -331,47 +330,24 @@ def test_get_otel_async_interceptor_enabled(monkeypatch): ClientOptions(api_endpoint="example.com:not_a_port"), {"server.address": "example.com", "url.domain": "googleapis.com"}, ), - ], -) -def test_extract_endpoint_attributes(client_options, expected_attrs): - """Proves that _extract_endpoint_attributes correctly parses server.address, non-default server.port, and url.domain.""" - assert _observability._extract_endpoint_attributes(client_options) == expected_attrs - - -@pytest.mark.parametrize( - "req,expected_attrs", - [ - (None, {"rpc.system.name": "grpc"}), - (types.SimpleNamespace(), {"rpc.system.name": "grpc"}), - ( - types.SimpleNamespace(name="projects/p1/secrets/s1"), - {"rpc.system.name": "grpc"}, - ), ( - types.SimpleNamespace(parent="projects/parent-p1"), - {"rpc.system.name": "grpc"}, - ), - ( - types.SimpleNamespace(name="projects/p1/secrets/s1", resend_count=2), - { - "rpc.system.name": "grpc", - "gcp.grpc.resend_count": 2, - }, + ClientOptions(api_endpoint="http://[invalid:ipv6:80/"), + {"url.domain": "googleapis.com"}, ), ( - types.SimpleNamespace(resend_count=0), - {"rpc.system.name": "grpc"}, + ClientOptions(api_endpoint="example.com:99999"), + {"server.address": "example.com", "url.domain": "googleapis.com"}, ), ], ) -def test_extract_grpc_request_attributes(req, expected_attrs): - """Proves that _extract_grpc_request_attributes extracts all T4 gRPC attributes.""" - assert _observability._extract_grpc_request_attributes(req) == expected_attrs +def test_extract_endpoint_attributes(client_options, expected_attrs): + """Proves that _extract_endpoint_attributes correctly parses server.address, non-default server.port, and url.domain.""" + assert _observability._extract_endpoint_attributes(client_options) == expected_attrs def test_grpc_client_request_hook(): """Proves that _grpc_client_request_hook attaches extracted T4 attributes to recording spans, - normalizes span names, sets fully qualified rpc.method, and removes legacy rpc.system. + normalizes span names, sets fully qualified rpc.method, and allows legacy rpc.system to coexist. """ # Non-recording span should not set attributes mock_span_non_rec = mock.Mock() @@ -389,9 +365,8 @@ def test_grpc_client_request_hook(): "/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" ) mock_span_rec._attributes = {"rpc.system": "grpc"} - req = types.SimpleNamespace(name="projects/my-proj/secrets/s1", resend_count=1) - _observability._grpc_client_request_hook(mock_span_rec, req) + _observability._grpc_client_request_hook(mock_span_rec, mock.Mock()) # Verify span name normalized and rpc.method set to fully qualified name mock_span_rec.update_name.assert_called_once_with( @@ -401,57 +376,63 @@ def test_grpc_client_request_hook(): "rpc.method", "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" ) - # Verify rpc.system.name set and legacy rpc.system popped + # Verify rpc.system.name set and legacy rpc.system left intact mock_span_rec.set_attribute.assert_any_call("rpc.system.name", "grpc") - assert "rpc.system" not in mock_span_rec._attributes - - mock_span_rec.set_attribute.assert_any_call("gcp.grpc.resend_count", 1) + assert mock_span_rec._attributes["rpc.system"] == "grpc" - # Custom hook with endpoint attributes and already-clean span name + # Custom hook with endpoint attributes and already-clean span name (no leading slash) endpoint_hook = _observability._make_grpc_client_request_hook( {"server.address": "custom.api.com", "server.port": 443} ) mock_span_custom = mock.Mock() mock_span_custom.is_recording.return_value = True - mock_span_custom.name = "already_clean_name" - endpoint_hook(mock_span_custom, req) + mock_span_custom.name = ( + "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) + endpoint_hook(mock_span_custom, None) mock_span_custom.set_attribute.assert_any_call("server.address", "custom.api.com") mock_span_custom.set_attribute.assert_any_call("server.port", 443) + mock_span_custom.set_attribute.assert_any_call( + "rpc.method", "google.cloud.secretmanager.v1.SecretManagerService/ListSecrets" + ) mock_span_custom.update_name.assert_not_called() def test_grpc_client_request_hook_span_edge_cases(): """Proves that _grpc_client_request_hook handles spans lacking update_name, - spans with None _attributes, and spans with un-poppable _attributes gracefully. + spans with None or non-string names, and empty string names gracefully. """ - # 1. Leading slash in span.name but span lacks update_name (exercises 154->159) - mock_span_no_update = mock.Mock( - spec=["is_recording", "name", "_attributes", "set_attribute"] - ) + # 1. Leading slash in span.name but span lacks update_name + mock_span_no_update = mock.Mock(spec=["is_recording", "name", "set_attribute"]) mock_span_no_update.is_recording.return_value = True mock_span_no_update.name = "/package.Service/Method" - mock_span_no_update._attributes = {"rpc.system": "grpc"} _observability._grpc_client_request_hook(mock_span_no_update, None) mock_span_no_update.set_attribute.assert_any_call( "rpc.method", "package.Service/Method" ) + mock_span_no_update.set_attribute.assert_any_call("rpc.system.name", "grpc") + + # 2. Span with None name + mock_span_none_name = mock.Mock(spec=["is_recording", "name", "set_attribute"]) + mock_span_none_name.is_recording.return_value = True + mock_span_none_name.name = None + _observability._grpc_client_request_hook(mock_span_none_name, None) + mock_span_none_name.set_attribute.assert_any_call("rpc.system.name", "grpc") + assert not any( + call.args[0] == "rpc.method" + for call in mock_span_none_name.set_attribute.call_args_list + ) - # 2. Span with None _attributes (exercises 160->165) - mock_span_no_attrs = mock.Mock(spec=["is_recording", "name", "set_attribute"]) - mock_span_no_attrs.is_recording.return_value = True - mock_span_no_attrs.name = "clean_name" - _observability._grpc_client_request_hook(mock_span_no_attrs, None) - mock_span_no_attrs.set_attribute.assert_any_call("rpc.system.name", "grpc") - - # 3. Span with non-dict / un-poppable _attributes (exercises 162->165) - mock_span_unpoppable = mock.Mock( - spec=["is_recording", "name", "_attributes", "set_attribute"] + # 3. Span with empty string name + mock_span_empty_name = mock.Mock(spec=["is_recording", "name", "set_attribute"]) + mock_span_empty_name.is_recording.return_value = True + mock_span_empty_name.name = "" + _observability._grpc_client_request_hook(mock_span_empty_name, None) + mock_span_empty_name.set_attribute.assert_any_call("rpc.system.name", "grpc") + assert not any( + call.args[0] == "rpc.method" + for call in mock_span_empty_name.set_attribute.call_args_list ) - mock_span_unpoppable.is_recording.return_value = True - mock_span_unpoppable.name = "clean_name" - mock_span_unpoppable._attributes = object() - _observability._grpc_client_request_hook(mock_span_unpoppable, None) - mock_span_unpoppable.set_attribute.assert_any_call("rpc.system.name", "grpc") def test_get_otel_interceptor_with_api_endpoint(monkeypatch): From 0fb354a1db91c5e140b900ef93a52257c804ee0f Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 14 Sep 2026 04:57:49 -0400 Subject: [PATCH 17/27] docs(observability): clarify sync vs async behavior and specify semconv version in response hook --- .../google/api_core/_observability.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 5e70c95d85b6..2d8c50acbfa9 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -161,11 +161,22 @@ def _grpc_client_response_hook(span: Any, response: Any) -> None: Upstream ``opentelemetry-instrumentation-grpc`` sets the integer status code ``rpc.grpc.status_code`` (e.g. 0), but does not record the modern string status ``rpc.response.status_code`` (e.g. "OK") required by Cloud Trace and current - OpenTelemetry semantic conventions. + OpenTelemetry semantic conventions (v1.27.0+). This hook enriches successful RPC attempt spans with ``rpc.response.status_code = "OK"``. Errors and non-OK statuses are handled at the Tier 3 method span layer or upstream. + Upstream handles synchronous and asynchronous invocations differently: + - **Synchronous gRPC**: Upstream only invokes the response hook when an RPC call + succeeds. On failure, the hook is bypassed entirely. + - **Asynchronous gRPC**: Upstream invokes the response hook unconditionally for + both successes and failures (passing exception details on error). However, it + always marks ``span.status`` with an error status before calling the hook. + + Because of this disparity, this hook checks ``span.status`` to guard against + async failure callbacks while allowing synchronous and successful asynchronous + calls to be marked "OK". + Note: If upstream ``opentelemetry-instrumentation-grpc`` adds native support for modern ``rpc.response.status_code`` in future releases, this hook can be retired. @@ -177,9 +188,7 @@ def _grpc_client_response_hook(span: Any, response: Any) -> None: if not span.is_recording(): return - # Verify the RPC succeeded before recording the OK response status. - # Upstream async instrumentation invokes this hook on both successes - # and failures, so check whether an error status was already recorded. + # Guard against upstream async calls that invoke this hook on failures. status = getattr(span, "status", None) status_code = getattr(status, "status_code", None) if ( From acce308b77bbe62c68cab2e229483f8ea517a2fd Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 07:12:21 -0400 Subject: [PATCH 18/27] feat(gapic): add OpenTelemetry channel tracing to generator templates --- .../%sub/services/%service/client.py.j2 | 74 ++++++++++------ .../services/%service/transports/grpc.py.j2 | 20 ++++- .../%name_%version/%sub/test_%service.py.j2 | 85 +++++++++++++++++++ 3 files changed, 153 insertions(+), 26 deletions(-) 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..37d55e659097 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 @@ -53,6 +53,13 @@ try: except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# mypy: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) {% filter sort_lines %} @@ -314,17 +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, @@ -531,19 +538,38 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): else cast(Callable[..., {{ service.name }}Transport], transport) ) {% endif %} + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + {% if 'grpc' in opts.transport %} + if ( + transport_init is {{ service.grpc_transport_name }} + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + {% endif %} + # 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, + **({"interceptors": interceptors} if interceptors 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 +853,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/grpc.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 index e906c9d9ea71..b644db807432 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 @@ -11,6 +11,7 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union from google.api_core import grpc_helpers +from google.api_core.grpc_helpers import ClientInterceptor {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} @@ -80,7 +81,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 +124,14 @@ 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, ) -> None: """Instantiate the transport. @@ -143,7 +152,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 +182,9 @@ 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. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -252,6 +264,10 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): ], ) + self._grpc_channel = grpc_helpers.apply_channel_interceptors( + self._grpc_channel, interceptors + ) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index 68e754caf287..832d735656c9 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -904,6 +904,91 @@ def test_{{ service.client_name|snake_case }}_client_options_from_dict(): ) +def test_{{ service.client_name|snake_case }}_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.client._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_transport_name }}, "__init__", return_value=None + ) as patched_transport_init, + ): + client = {{ service.client_name }}(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_{{ service.client_name|snake_case }}_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join('.') }}.services.{{ service.name|snake_case }}.client._observability", + mock_obs, + ), + mock.patch.object( + transports.{{ service.grpc_transport_name }}, "__init__", return_value=None + ) as patched_transport_init, + ): + client = {{ service.client_name }}(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +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, + ) as mock_apply_interceptors, + ): + transport = transports.{{ service.grpc_transport_name }}( + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_{{ service.name|snake_case }}_grpc_transport_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, + ) as mock_apply_interceptors: + transport = transports.{{ service.grpc_transport_name }}( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ ({{ service.client_name }}, transports.{{ service.grpc_transport_name }}, "grpc", grpc_helpers), ({{ service.async_client_name }}, transports.{{ service.grpc_asyncio_transport_name }}, "grpc_asyncio", grpc_helpers_async), From d337933a4dd5098954195333cf962f5e97150451 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 07:36:34 -0400 Subject: [PATCH 19/27] fix(gapic): resolve CI import errors on unreleased ClientInterceptor and fix mypy comment --- .../%sub/services/%service/client.py.j2 | 2 +- .../services/%service/transports/grpc.py.j2 | 22 +++++++++++++++---- .../%name_%version/%sub/test_%service.py.j2 | 2 ++ 3 files changed, 21 insertions(+), 5 deletions(-) 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 37d55e659097..5156805f1e38 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 @@ -54,7 +54,7 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# mypy: _observability was added in google-api-core 2.35.0; guard for older versions +# Note: _observability was added in google-api-core 2.35.0; guard for older versions try: from google.api_core import _observability # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 b644db807432..75b6304c0316 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 @@ -10,8 +10,20 @@ import pickle import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers -from google.api_core.grpc_helpers import ClientInterceptor + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} @@ -22,7 +34,6 @@ 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 %} @@ -264,9 +275,12 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): ], ) - self._grpc_channel = grpc_helpers.apply_channel_interceptors( - self._grpc_channel, interceptors + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index 832d735656c9..a0fe129f98d9 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 @@ -957,6 +957,7 @@ def test_{{ service.name|snake_case }}_grpc_transport_channel_interceptors(): grpc_helpers, "apply_channel_interceptors", return_value=mock_channel, + create=True, ) as mock_apply_interceptors, ): transport = transports.{{ service.grpc_transport_name }}( @@ -977,6 +978,7 @@ def test_{{ service.name|snake_case }}_grpc_transport_custom_channel_interceptor 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, From 13f12183a0e5df73fb918bb172bc1a5f3d55a3ba Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 07:44:15 -0400 Subject: [PATCH 20/27] fix(gapic): use AnonymousCredentials in test_grpc_transport_channel_interceptors --- .../tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 | 1 + 1 file changed, 1 insertion(+) 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 a0fe129f98d9..ec499686a51d 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 @@ -961,6 +961,7 @@ def test_{{ service.name|snake_case }}_grpc_transport_channel_interceptors(): ) as mock_apply_interceptors, ): transport = transports.{{ service.grpc_transport_name }}( + credentials=ga_credentials.AnonymousCredentials(), interceptors=[mock_interceptor], ) From 11ccd0d9df59638b1146ae59df5d8ebf4bf1b998 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 11 Sep 2026 12:47:44 -0400 Subject: [PATCH 21/27] test(gapic): update bazel integration goldens for otel channel tracing --- .../asset_v1/services/asset_service/client.py | 46 +++++++--- .../services/asset_service/transports/grpc.py | 32 ++++++- .../unit/gapic/asset_v1/test_asset_service.py | 88 +++++++++++++++++++ .../services/iam_credentials/client.py | 46 +++++++--- .../iam_credentials/transports/grpc.py | 32 ++++++- .../credentials_v1/test_iam_credentials.py | 88 +++++++++++++++++++ .../eventarc_v1/services/eventarc/client.py | 46 +++++++--- .../services/eventarc/transports/grpc.py | 32 ++++++- .../unit/gapic/eventarc_v1/test_eventarc.py | 88 +++++++++++++++++++ .../services/config_service_v2/client.py | 46 +++++++--- .../config_service_v2/transports/grpc.py | 32 ++++++- .../services/logging_service_v2/client.py | 46 +++++++--- .../logging_service_v2/transports/grpc.py | 32 ++++++- .../services/metrics_service_v2/client.py | 46 +++++++--- .../metrics_service_v2/transports/grpc.py | 32 ++++++- .../logging_v2/test_config_service_v2.py | 88 +++++++++++++++++++ .../logging_v2/test_logging_service_v2.py | 88 +++++++++++++++++++ .../logging_v2/test_metrics_service_v2.py | 88 +++++++++++++++++++ .../services/config_service_v2/client.py | 46 +++++++--- .../config_service_v2/transports/grpc.py | 32 ++++++- .../services/logging_service_v2/client.py | 46 +++++++--- .../logging_service_v2/transports/grpc.py | 32 ++++++- .../services/metrics_service_v2/client.py | 46 +++++++--- .../metrics_service_v2/transports/grpc.py | 32 ++++++- .../logging_v2/test_config_service_v2.py | 88 +++++++++++++++++++ .../logging_v2/test_logging_service_v2.py | 88 +++++++++++++++++++ .../logging_v2/test_metrics_service_v2.py | 88 +++++++++++++++++++ .../redis_v1/services/cloud_redis/client.py | 46 +++++++--- .../services/cloud_redis/transports/grpc.py | 32 ++++++- .../unit/gapic/redis_v1/test_cloud_redis.py | 88 +++++++++++++++++++ .../redis_v1/services/cloud_redis/client.py | 46 +++++++--- .../services/cloud_redis/transports/grpc.py | 32 ++++++- .../unit/gapic/redis_v1/test_cloud_redis.py | 88 +++++++++++++++++++ .../storage_batch_operations/client.py | 46 +++++++--- .../transports/grpc.py | 32 ++++++- .../test_storage_batch_operations.py | 88 +++++++++++++++++++ 36 files changed, 1848 insertions(+), 144 deletions(-) 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..12e669a8e11c 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.asset_v1.services.asset_service import pagers @@ -545,18 +552,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., AssetServiceTransport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is AssetServiceGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py index 848bb1096cbe..498ecc1dfa24 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.asset_v1.types import asset_service @@ -132,6 +144,14 @@ 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, ) -> None: """Instantiate the transport. @@ -182,6 +202,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -259,6 +282,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py index e86b23c549e4..1b833561fbe0 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py @@ -760,6 +760,94 @@ def test_asset_service_client_client_options_from_dict(): ) +def test_asset_service_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = AssetServiceClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_asset_service_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.asset_v1.services.asset_service.client._observability", + mock_obs, + ), + mock.patch.object( + transports.AssetServiceGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = AssetServiceClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_asset_service_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.AssetServiceGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.AssetServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_asset_service_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.AssetServiceGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (AssetServiceClient, transports.AssetServiceGrpcTransport, "grpc", grpc_helpers), (AssetServiceAsyncClient, transports.AssetServiceGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..65d424866a2a 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.iam.credentials_v1.types import common @@ -482,18 +489,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., IAMCredentialsTransport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is IAMCredentialsGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py index 18428ad7d6e0..7721d3534d56 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -27,7 +40,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.iam.credentials_v1.types import common @@ -138,6 +150,14 @@ 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, ) -> None: """Instantiate the transport. @@ -188,6 +208,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -264,6 +287,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py index a13fa010afd5..dfc140216554 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py @@ -750,6 +750,94 @@ def test_iam_credentials_client_client_options_from_dict(): ) +def test_iam_credentials_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.client._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IAMCredentialsClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_iam_credentials_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.iam.credentials_v1.services.iam_credentials.client._observability", + mock_obs, + ), + mock.patch.object( + transports.IAMCredentialsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = IAMCredentialsClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_iam_credentials_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.IAMCredentialsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.IAMCredentialsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_iam_credentials_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.IAMCredentialsGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (IAMCredentialsClient, transports.IAMCredentialsGrpcTransport, "grpc", grpc_helpers), (IAMCredentialsAsyncClient, transports.IAMCredentialsGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..9cf97196a76d 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.eventarc_v1.services.eventarc import pagers @@ -665,18 +672,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., EventarcTransport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is EventarcGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py index ac5d9a0fbe92..9435b1510b97 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.eventarc_v1.types import channel @@ -146,6 +158,14 @@ 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, ) -> None: """Instantiate the transport. @@ -196,6 +216,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -273,6 +296,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py index 3720a1a84418..d1feb6c06e6b 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py @@ -781,6 +781,94 @@ def test_eventarc_client_client_options_from_dict(): ) +def test_eventarc_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.client._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EventarcClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_eventarc_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.eventarc_v1.services.eventarc.client._observability", + mock_obs, + ), + mock.patch.object( + transports.EventarcGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = EventarcClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_eventarc_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.EventarcGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.EventarcGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_eventarc_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.EventarcGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (EventarcClient, transports.EventarcGrpcTransport, "grpc", grpc_helpers), (EventarcAsyncClient, transports.EventarcGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..4dc15021b0f6 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.config_service_v2 import pagers @@ -538,18 +545,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ConfigServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is ConfigServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index d8122989787f..0bd5df37a0d4 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_config @@ -132,6 +144,14 @@ 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, ) -> None: """Instantiate the transport. @@ -182,6 +202,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -259,6 +282,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/client.py index dfaf6928a16d..a37a1d051862 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.logging_service_v2 import pagers @@ -469,18 +476,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., LoggingServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is LoggingServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index eeb3a8564ee0..2f37739ca465 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -27,7 +40,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging @@ -131,6 +143,14 @@ 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, ) -> None: """Instantiate the transport. @@ -181,6 +201,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -257,6 +280,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/client.py index 7319be93a38c..81e991c620d8 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.metrics_service_v2 import pagers @@ -470,18 +477,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., MetricsServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is MetricsServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index 2b6003f77476..2a27a9753aa9 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -27,7 +40,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_metrics @@ -131,6 +143,14 @@ 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, ) -> None: """Instantiate the transport. @@ -181,6 +201,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -257,6 +280,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py index 638aac7a87f8..94a39fe4f05c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -736,6 +736,94 @@ def test_config_service_v2_client_client_options_from_dict(): ) +def test_config_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ConfigServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_config_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = ConfigServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_config_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.ConfigServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (ConfigServiceV2Client, transports.ConfigServiceV2GrpcTransport, "grpc", grpc_helpers), (ConfigServiceV2AsyncClient, transports.ConfigServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..ec398711b928 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -737,6 +737,94 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +def test_logging_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_logging_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_logging_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.LoggingServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (LoggingServiceV2Client, transports.LoggingServiceV2GrpcTransport, "grpc", grpc_helpers), (LoggingServiceV2AsyncClient, transports.LoggingServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..bc55c44d2a43 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -735,6 +735,94 @@ def test_metrics_service_v2_client_client_options_from_dict(): ) +def test_metrics_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MetricsServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_metrics_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = MetricsServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_metrics_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.MetricsServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (MetricsServiceV2Client, transports.MetricsServiceV2GrpcTransport, "grpc", grpc_helpers), (MetricsServiceV2AsyncClient, transports.MetricsServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..c06e45ec5def 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.config_service_v2 import pagers @@ -538,18 +545,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., ConfigServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is ConfigServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index d8122989787f..0bd5df37a0d4 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_config @@ -132,6 +144,14 @@ 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, ) -> None: """Instantiate the transport. @@ -182,6 +202,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -259,6 +282,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/client.py index dfaf6928a16d..a37a1d051862 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.logging_service_v2 import pagers @@ -469,18 +476,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., LoggingServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is LoggingServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index eeb3a8564ee0..2f37739ca465 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -27,7 +40,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging @@ -131,6 +143,14 @@ 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, ) -> None: """Instantiate the transport. @@ -181,6 +201,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -257,6 +280,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/client.py index 46949c293cd9..4d9582f65bf4 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.logging_v2.services.metrics_service_v2 import pagers @@ -470,18 +477,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., MetricsServiceV2Transport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is MetricsServiceV2GrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index 2b6003f77476..2a27a9753aa9 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -27,7 +40,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.logging_v2.types import logging_metrics @@ -131,6 +143,14 @@ 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, ) -> None: """Instantiate the transport. @@ -181,6 +201,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -257,6 +280,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py index c63237e51f6c..e6de5df4ceaf 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -736,6 +736,94 @@ def test_base_config_service_v2_client_client_options_from_dict(): ) +def test_base_config_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseConfigServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_base_config_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.config_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseConfigServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_config_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.ConfigServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.ConfigServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_config_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.ConfigServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (BaseConfigServiceV2Client, transports.ConfigServiceV2GrpcTransport, "grpc", grpc_helpers), (BaseConfigServiceV2AsyncClient, transports.ConfigServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..ec398711b928 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -737,6 +737,94 @@ def test_logging_service_v2_client_client_options_from_dict(): ) +def test_logging_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_logging_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.logging_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = LoggingServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_logging_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.LoggingServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.LoggingServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_logging_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.LoggingServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (LoggingServiceV2Client, transports.LoggingServiceV2GrpcTransport, "grpc", grpc_helpers), (LoggingServiceV2AsyncClient, transports.LoggingServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..59ceebba8a28 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -735,6 +735,94 @@ def test_base_metrics_service_v2_client_client_options_from_dict(): ) +def test_base_metrics_service_v2_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseMetricsServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_base_metrics_service_v2_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.logging_v2.services.metrics_service_v2.client._observability", + mock_obs, + ), + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = BaseMetricsServiceV2Client(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_metrics_service_v2_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.MetricsServiceV2GrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.MetricsServiceV2GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_metrics_service_v2_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.MetricsServiceV2GrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (BaseMetricsServiceV2Client, transports.MetricsServiceV2GrpcTransport, "grpc", grpc_helpers), (BaseMetricsServiceV2AsyncClient, transports.MetricsServiceV2GrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..54ec658e93aa 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -532,18 +539,35 @@ 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, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is CloudRedisGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index addfbf37e166..b2a32b47bb95 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -152,6 +164,14 @@ 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, ) -> None: """Instantiate the transport. @@ -202,6 +222,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -279,6 +302,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py index 6bd8b8b5009c..632bd64909f4 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -768,6 +768,94 @@ def test_cloud_redis_client_client_options_from_dict(): ) +def test_cloud_redis_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_cloud_redis_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_cloud_redis_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.CloudRedisGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (CloudRedisClient, transports.CloudRedisGrpcTransport, "grpc", grpc_helpers), (CloudRedisAsyncClient, transports.CloudRedisGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..a9817bce76fb 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 @@ -47,6 +47,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -532,18 +539,35 @@ 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, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is CloudRedisGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index cae682b3d0ae..c05af1e2e635 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -152,6 +164,14 @@ 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, ) -> None: """Instantiate the transport. @@ -202,6 +222,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -279,6 +302,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py index 44a69d3d2277..9094b0af41d2 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -768,6 +768,94 @@ def test_cloud_redis_client_client_options_from_dict(): ) +def test_cloud_redis_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_cloud_redis_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.redis_v1.services.cloud_redis.client._observability", + mock_obs, + ), + mock.patch.object( + transports.CloudRedisGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = CloudRedisClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_cloud_redis_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.CloudRedisGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.CloudRedisGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_cloud_redis_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.CloudRedisGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (CloudRedisClient, transports.CloudRedisGrpcTransport, "grpc", grpc_helpers), (CloudRedisAsyncClient, transports.CloudRedisGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), 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..1ef4640b848d 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 @@ -49,6 +49,13 @@ except ImportError: # pragma: NO COVER CLIENT_LOGGING_SUPPORTED = False +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: _observability was added in google-api-core 2.35.0; guard for older versions +try: + from google.api_core import _observability # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + _observability = None # type: ignore[assignment] + _LOGGER = std_logging.getLogger(__name__) from google.cloud.location import locations_pb2 # type: ignore @@ -506,18 +513,35 @@ def __init__(self, *, if isinstance(transport, str) or transport is None else cast(Callable[..., StorageBatchOperationsTransport], transport) ) + # When OpenTelemetry tracing is enabled, obtain the channel interceptor + # and pass it to the transport. + interceptors = [] + if ( + transport_init is StorageBatchOperationsGrpcTransport + and _observability is not None + and ( + otel_interceptor := _observability.get_otel_interceptor( + self._client_options + ) + ) + is not None + ): + interceptors.append(otel_interceptor) + # 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, + **({"interceptors": interceptors} if interceptors 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/grpc.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py index 1f997d49aabd..033f96587427 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 @@ -19,7 +19,20 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import grpc # type: ignore from google.api_core import grpc_helpers + +# Optional: OpenTelemetry tracing capabilities for grpc channel injection +# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +try: + from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] +except ImportError: # pragma: NO COVER + ClientInterceptor = Union[ # type: ignore[misc,assignment] + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, + ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -28,7 +41,6 @@ from google.protobuf.json_format import MessageToJson import google.protobuf.message -import grpc # type: ignore import proto # type: ignore from google.cloud.location import locations_pb2 # type: ignore @@ -138,6 +150,14 @@ 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, ) -> None: """Instantiate the transport. @@ -188,6 +208,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. + interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]): + Additional interceptors (or callables that apply interceptors) to apply to the + gRPC channel. Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport @@ -265,6 +288,13 @@ def __init__(self, *, ], ) + apply_interceptors = getattr( + grpc_helpers, + "apply_channel_interceptors", + lambda channel, interceptors: channel, + ) + self._grpc_channel = apply_interceptors(self._grpc_channel, interceptors) + self._interceptor = _LoggingClientInterceptor() self._logged_channel = grpc.intercept_channel(self._grpc_channel, self._interceptor) diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py index 91d1b992fe18..2d51c66c5a73 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py @@ -760,6 +760,94 @@ def test_storage_batch_operations_client_client_options_from_dict(): ) +def test_storage_batch_operations_client_otel_channel_injection_enabled(): + mock_interceptor = mock.Mock() + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = mock_interceptor + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.client._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = StorageBatchOperationsClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert called_kwargs.get("interceptors") == [mock_interceptor] + + +def test_storage_batch_operations_client_otel_channel_injection_disabled(): + mock_obs = mock.Mock() + mock_obs.get_otel_interceptor.return_value = None + with ( + mock.patch( + "google.cloud.storagebatchoperations_v1.services.storage_batch_operations.client._observability", + mock_obs, + ), + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, "__init__", return_value=None + ) as patched_transport_init, + ): + client = StorageBatchOperationsClient(transport="grpc") + + mock_obs.get_otel_interceptor.assert_called_once_with(client._client_options) + called_kwargs = patched_transport_init.call_args.kwargs + assert not called_kwargs.get("interceptors", []) + + +def test_storage_batch_operations_grpc_transport_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_channel = mock.Mock() + + with ( + mock.patch.object( + transports.StorageBatchOperationsGrpcTransport, + "create_channel", + return_value=mock_channel, + ), + mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_channel, + create=True, + ) as mock_apply_interceptors, + ): + transport = transports.StorageBatchOperationsGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_channel + + +def test_storage_batch_operations_grpc_transport_custom_channel_interceptors(): + mock_interceptor = mock.Mock() + mock_custom_channel = mock.Mock(spec=grpc.Channel) + + with mock.patch.object( + grpc_helpers, + "apply_channel_interceptors", + return_value=mock_custom_channel, + create=True, + ) as mock_apply_interceptors: + transport = transports.StorageBatchOperationsGrpcTransport( + channel=mock_custom_channel, + interceptors=[mock_interceptor], + ) + + mock_apply_interceptors.assert_called_once_with( + mock_custom_channel, [mock_interceptor] + ) + assert transport.grpc_channel == mock_custom_channel + + @pytest.mark.parametrize("client_class,transport_class,transport_name,grpc_helpers", [ (StorageBatchOperationsClient, transports.StorageBatchOperationsGrpcTransport, "grpc", grpc_helpers), (StorageBatchOperationsAsyncClient, transports.StorageBatchOperationsGrpcAsyncIOTransport, "grpc_asyncio", grpc_helpers_async), From a7dad4fda00da0e6d5ad01ee8fee7dc5c50f7136 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 14 Sep 2026 08:15:19 -0400 Subject: [PATCH 22/27] ci(gapic): add OpenTelemetry test dependencies to showcase nox sessions --- packages/gapic-generator/noxfile.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index a9724ae3b450..2dc40ae96b05 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -18,18 +18,18 @@ # PIP_INDEX_URL=https://pypi.org/simple nox from __future__ import absolute_import -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path + import os +import shutil import sys import tempfile import typing -import nox # type: ignore - +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from os import path -import shutil +from pathlib import Path +import nox # type: ignore nox.options.error_on_missing_interpreters = True @@ -407,6 +407,11 @@ def showcase( # Use pytest-asyncio<1.0.0 while we investigate the recent failure described in # https://github.com/googleapis/gapic-generator-python/issues/2399 session.install("pytest", "pytest-asyncio<1.0.0") + session.install( + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation-grpc", + ) test_directory = Path("tests", "system") ignore_file = env.get("IGNORE_FILE") pytest_command = [ @@ -498,7 +503,13 @@ def showcase_pqc( with showcase_library(session, templates=templates, other_opts=other_opts): session.install("pytest", "pytest-asyncio") session.install("--upgrade", "grpcio>=1.83.0", "grpcio-status>=1.83.0") - session.run("py.test", "--quiet", "--tls", *(session.posargs or ["tests/system/test_pqc.py"]), env=env) + session.run( + "py.test", + "--quiet", + "--tls", + *(session.posargs or ["tests/system/test_pqc.py"]), + env=env, + ) def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False): @@ -508,6 +519,8 @@ def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False "pytest-cov", "pytest-xdist", "pytest-asyncio", + "opentelemetry-api", + "opentelemetry-sdk", ) # Freeze and print python environment package versions session.run("python", "-m", "pip", "freeze") From 858ff5240a186483f853505e899bec3c1115efa7 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 14 Sep 2026 08:15:26 -0400 Subject: [PATCH 23/27] test(gapic): support client_options and otel interceptor in system test harness --- .../gapic-generator/tests/system/conftest.py | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/gapic-generator/tests/system/conftest.py b/packages/gapic-generator/tests/system/conftest.py index 73169dd8a79f..6d331f7a295e 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,9 +178,15 @@ 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 + interceptors = [] + if _observability is not None and transport_name == "grpc": + otel_interceptor = _observability.get_otel_interceptor(client_options) + if otel_interceptor is not None: + interceptors.append(otel_interceptor) transport = transport_cls( credentials=credentials, channel=channel_creator(transport_endpoint), + interceptors=interceptors if interceptors else None, ) elif transport_name in ["rest", "rest_asyncio"]: # The custom host explicitly bypasses https. @@ -187,7 +198,7 @@ def construct_client( else: raise RuntimeError(f"Unexpected transport type: {transport_name}") - client = client_class(transport=transport) + client = client_class(transport=transport, client_options=client_options) return client @@ -340,7 +351,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 +412,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 +473,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 +491,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 From b1c66e4d89af98119af4394b294defbd9ad57b75 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 14 Sep 2026 08:15:31 -0400 Subject: [PATCH 24/27] test(gapic): add showcase system test suite for OpenTelemetry channel tracing --- .../tests/system/test_tracing.py | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 packages/gapic-generator/tests/system/test_tracing.py 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..4857587525f9 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_tracing.py @@ -0,0 +1,210 @@ +# 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 pytest + +try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +if not HAS_OPENTELEMETRY: + pytest.skip("OpenTelemetry is not installed", allow_module_level=True) + +from google import showcase +from google.api_core import exceptions +from google.api_core import retry as retries +from google.api_core.client_options import ClientOptions +from google.auth import credentials as ga_credentials +from google.rpc import code_pb2 +from google.showcase import EchoClient + +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( + tracing_enabled=True, + tracer_provider=provider, + ) + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + return client, exporter + + +def test_sync_unary_tracing(otel_echo_client): + """Verifies that a synchronous unary RPC generates a trace span with expected attributes.""" + client, exporter = otel_echo_client + + response = client.echo(showcase.EchoRequest(content="hello world")) + assert response.content == "hello world" + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.name == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.system.name") == "grpc" + assert span.attributes.get("rpc.method") == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.response.status_code") == "OK" + assert span.attributes.get("url.domain") == "googleapis.com" + assert span.kind == trace.SpanKind.CLIENT + + +def test_unary_retries_tracing(span_exporter, use_mtls): + """Verifies that each attempt of a retried RPC generates a separate span.""" + exporter, provider = span_exporter + options = ClientOptions( + tracing_enabled=True, + tracer_provider=provider, + ) + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + + # Configure a custom retry policy with 2 attempts on DeadlineExceeded + custom_retry = retries.Retry( + predicate=retries.if_exception_type(exceptions.DeadlineExceeded), + initial=0.05, + maximum=0.1, + multiplier=1.0, + deadline=0.3, + ) + + with pytest.raises((exceptions.DeadlineExceeded, exceptions.RetryError)): + client.echo( + { + "error": { + "code": code_pb2.Code.Value("DEADLINE_EXCEEDED"), + "message": "Simulated deadline exceeded error for retry testing.", + }, + }, + retry=custom_retry, + ) + + spans = exporter.get_finished_spans() + # At least two attempts should have been made and recorded + assert len(spans) >= 2 + for span in spans: + assert span.name == "google.showcase.v1beta1.Echo/Echo" + assert span.attributes.get("rpc.system.name") == "grpc" + assert span.attributes.get("rpc.method") == "google.showcase.v1beta1.Echo/Echo" + # Non-successful attempt should not have rpc.response.status_code == "OK" + assert span.attributes.get("rpc.response.status_code") != "OK" + + +def test_tracing_disabled_default(use_mtls): + """Verifies that default client options emit zero spans (zero overhead guarantee).""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + client = construct_client( + EchoClient, + use_mtls, + 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 + assert len(exporter.get_finished_spans()) == 0 + + +def test_custom_tracer_provider(use_mtls): + """Verifies that spans are emitted exclusively to the injected custom TracerProvider.""" + custom_exporter = InMemorySpanExporter() + custom_provider = TracerProvider() + custom_provider.add_span_processor(SimpleSpanProcessor(custom_exporter)) + + other_exporter = InMemorySpanExporter() + other_provider = TracerProvider() + other_provider.add_span_processor(SimpleSpanProcessor(other_exporter)) + + options = ClientOptions( + tracing_enabled=True, + tracer_provider=custom_provider, + ) + 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" + + assert len(custom_exporter.get_finished_spans()) == 1 + assert len(other_exporter.get_finished_spans()) == 0 + + +def test_env_var_opt_in(span_exporter, use_mtls): + """Verifies that setting the environment variable enables tracing without tracing_enabled=True.""" + exporter, provider = span_exporter + + options = ClientOptions( + tracer_provider=provider, + ) + + env_patch = { + "GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED": "true", + } + with mock.patch.dict(os.environ, env_patch): + client = construct_client( + EchoClient, + use_mtls, + client_options=options, + credentials=ga_credentials.AnonymousCredentials(), + ) + response = client.echo(showcase.EchoRequest(content="env opt in")) + assert response.content == "env opt in" + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "google.showcase.v1beta1.Echo/Echo" From b409ba6846dde6b2aca225d36e21b3886df1973a Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 14 Sep 2026 09:22:29 -0400 Subject: [PATCH 25/27] feat(gapic): broaden transport subclass check and harden tracing tests - Broaden transport check in client.py.j2 to allow gRPC transport subclasses. - Align version comments in client.py.j2 and grpc.py.j2 to 2.36.0+. - Synchronize all golden client and transport files with template updates. - Harden zero-overhead and custom tracer provider isolation assertions in test_tracing.py. - Add direct client initialization test to verify template injection end-to-end. --- .../%sub/services/%service/client.py.j2 | 5 +- .../services/%service/transports/grpc.py.j2 | 2 +- .../asset_v1/services/asset_service/client.py | 5 +- .../services/asset_service/transports/grpc.py | 2 +- .../services/iam_credentials/client.py | 5 +- .../iam_credentials/transports/grpc.py | 2 +- .../eventarc_v1/services/eventarc/client.py | 5 +- .../services/eventarc/transports/grpc.py | 2 +- .../services/config_service_v2/client.py | 5 +- .../config_service_v2/transports/grpc.py | 2 +- .../services/logging_service_v2/client.py | 5 +- .../logging_service_v2/transports/grpc.py | 2 +- .../services/metrics_service_v2/client.py | 5 +- .../metrics_service_v2/transports/grpc.py | 2 +- .../services/config_service_v2/client.py | 5 +- .../config_service_v2/transports/grpc.py | 2 +- .../services/logging_service_v2/client.py | 5 +- .../logging_service_v2/transports/grpc.py | 2 +- .../services/metrics_service_v2/client.py | 5 +- .../metrics_service_v2/transports/grpc.py | 2 +- .../redis_v1/services/cloud_redis/client.py | 5 +- .../services/cloud_redis/transports/grpc.py | 2 +- .../redis_v1/services/cloud_redis/client.py | 5 +- .../services/cloud_redis/transports/grpc.py | 2 +- .../storage_batch_operations/client.py | 5 +- .../transports/grpc.py | 2 +- .../tests/system/test_tracing.py | 103 +++++++++++++++--- 27 files changed, 139 insertions(+), 55 deletions(-) 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 5156805f1e38..157f014508dd 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 @@ -54,7 +54,7 @@ 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.35.0; guard for older versions +# 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 @@ -543,7 +543,8 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): interceptors = [] {% if 'grpc' in opts.transport %} if ( - transport_init is {{ service.grpc_transport_name }} + isinstance(transport_init, type) + and issubclass(transport_init, {{ service.grpc_transport_name }}) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 75b6304c0316..8da1dfcce160 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 @@ -14,7 +14,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 12e669a8e11c..492935dd8e5a 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -556,7 +556,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is AssetServiceGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, AssetServiceGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 498ecc1dfa24..1c92f07d98b4 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 65d424866a2a..814806e76f0a 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -493,7 +493,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is IAMCredentialsGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, IAMCredentialsGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 7721d3534d56..a1940f375c9c 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 9cf97196a76d..1df357a593f2 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -676,7 +676,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is EventarcGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, EventarcGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 9435b1510b97..503167d1e16b 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 4dc15021b0f6..de57e35ed2ac 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -549,7 +549,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is ConfigServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, ConfigServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 0bd5df37a0d4..940e3a761b31 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 a37a1d051862..9fefe7597513 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -480,7 +480,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is LoggingServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, LoggingServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 2f37739ca465..b97b20ffe806 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 81e991c620d8..0e683c58063f 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -481,7 +481,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is MetricsServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, MetricsServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 2a27a9753aa9..dab5b52cf0dc 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 c06e45ec5def..aefac0da88fb 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -549,7 +549,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is ConfigServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, ConfigServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 0bd5df37a0d4..940e3a761b31 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 a37a1d051862..9fefe7597513 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -480,7 +480,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is LoggingServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, LoggingServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 2f37739ca465..b97b20ffe806 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 4d9582f65bf4..c636aaca7e86 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -481,7 +481,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is MetricsServiceV2GrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, MetricsServiceV2GrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 2a27a9753aa9..dab5b52cf0dc 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 54ec658e93aa..e8f258ff21d3 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -543,7 +543,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is CloudRedisGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, CloudRedisGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 b2a32b47bb95..becf980c03c1 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 a9817bce76fb..828f6d48211e 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 @@ -48,7 +48,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -543,7 +543,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is CloudRedisGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, CloudRedisGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 c05af1e2e635..0cc07e360d58 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER 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 1ef4640b848d..448ac3f79873 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 @@ -50,7 +50,7 @@ CLIENT_LOGGING_SUPPORTED = False # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: _observability was added in google-api-core 2.35.0; guard for older versions +# 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 @@ -517,7 +517,8 @@ def __init__(self, *, # and pass it to the transport. interceptors = [] if ( - transport_init is StorageBatchOperationsGrpcTransport + isinstance(transport_init, type) + and issubclass(transport_init, StorageBatchOperationsGrpcTransport) and _observability is not None and ( otel_interceptor := _observability.get_otel_interceptor( 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 033f96587427..d3555f1bf2a5 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 @@ -23,7 +23,7 @@ from google.api_core import grpc_helpers # Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.25.0+; fallback for older versions +# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions try: from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/system/test_tracing.py b/packages/gapic-generator/tests/system/test_tracing.py index 4857587525f9..b187a7ba2015 100644 --- a/packages/gapic-generator/tests/system/test_tracing.py +++ b/packages/gapic-generator/tests/system/test_tracing.py @@ -15,6 +15,7 @@ import os from unittest import mock +import grpc import pytest try: @@ -138,14 +139,30 @@ def test_unary_retries_tracing(span_exporter, use_mtls): def test_tracing_disabled_default(use_mtls): - """Verifies that default client options emit zero spans (zero overhead guarantee).""" + """Verifies that default client options emit zero spans (zero overhead guarantee). + + Ensures that configuring a `TracerProvider` in `ClientOptions` without explicitly + enabling tracing (via `tracing_enabled=True` or the environment variable + `GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED`) records zero spans and incurs + no tracing overhead. + + An active `TracerProvider` with an in-memory exporter is passed to the client. + The test executes an actual unary RPC and asserts that no finished spans are + recorded. + """ exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) + # Provide the provider, but leave tracing_enabled=False / unset + options = ClientOptions( + tracing_enabled=False, + tracer_provider=provider, + ) client = construct_client( EchoClient, use_mtls, + client_options=options, credentials=ga_credentials.AnonymousCredentials(), ) @@ -157,31 +174,85 @@ def test_tracing_disabled_default(use_mtls): def test_custom_tracer_provider(use_mtls): - """Verifies that spans are emitted exclusively to the injected custom TracerProvider.""" + """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)) - other_exporter = InMemorySpanExporter() - other_provider = TracerProvider() - other_provider.add_span_processor(SimpleSpanProcessor(other_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( + tracing_enabled=True, + tracer_provider=custom_provider, + ) + 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" + + assert len(custom_exporter.get_finished_spans()) == 1 + assert len(global_exporter.get_finished_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( tracing_enabled=True, - tracer_provider=custom_provider, - ) - client = construct_client( - EchoClient, - use_mtls, - client_options=options, - credentials=ga_credentials.AnonymousCredentials(), + tracer_provider=provider, ) - response = client.echo(showcase.EchoRequest(content="isolated trace")) - assert response.content == "isolated trace" + 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" - assert len(custom_exporter.get_finished_spans()) == 1 - assert len(other_exporter.get_finished_spans()) == 0 + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "google.showcase.v1beta1.Echo/Echo" + assert spans[0].attributes.get("rpc.system.name") == "grpc" def test_env_var_opt_in(span_exporter, use_mtls): From 2059f32059336c8a1e276e05e98b66ff75a00f55 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 15 Sep 2026 10:29:11 -0400 Subject: [PATCH 26/27] refactor(gapic): guard ClientInterceptor under TYPE_CHECKING in transport template - Place ClientInterceptor import under if TYPE_CHECKING: in grpc.py.j2 to eliminate runtime import overhead and avoid import failures on older google-api-core versions. - String-quote "ClientInterceptor" in the interceptors type annotation for GrpcTransport.__init__. - Regenerate and synchronize all golden gRPC transport files. --- .../%sub/services/%service/transports/grpc.py.j2 | 16 ++++------------ .../services/asset_service/transports/grpc.py | 16 ++++------------ .../services/iam_credentials/transports/grpc.py | 16 ++++------------ .../services/eventarc/transports/grpc.py | 16 ++++------------ .../config_service_v2/transports/grpc.py | 16 ++++------------ .../logging_service_v2/transports/grpc.py | 16 ++++------------ .../metrics_service_v2/transports/grpc.py | 16 ++++------------ .../config_service_v2/transports/grpc.py | 16 ++++------------ .../logging_service_v2/transports/grpc.py | 16 ++++------------ .../metrics_service_v2/transports/grpc.py | 16 ++++------------ .../services/cloud_redis/transports/grpc.py | 16 ++++------------ .../services/cloud_redis/transports/grpc.py | 16 ++++------------ .../storage_batch_operations/transports/grpc.py | 16 ++++------------ 13 files changed, 52 insertions(+), 156 deletions(-) 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 8da1dfcce160..6d281c171caf 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,22 +8,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} @@ -138,7 +130,7 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 1c92f07d98b4..1d6580b80ed4 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -147,7 +139,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 a1940f375c9c..b29bf163cc58 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,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -153,7 +145,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 503167d1e16b..0e18c592e1ac 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -161,7 +153,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 940e3a761b31..5b427443797b 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -147,7 +139,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 b97b20ffe806..4e2e4196e99b 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -146,7 +138,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 dab5b52cf0dc..1dc6897f9301 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -146,7 +138,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 940e3a761b31..5b427443797b 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -147,7 +139,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 b97b20ffe806..4e2e4196e99b 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -146,7 +138,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 dab5b52cf0dc..1dc6897f9301 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import gapic_v1 import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore @@ -146,7 +138,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 becf980c03c1..e72d49a74546 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -167,7 +159,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 0cc07e360d58..5658d2c5826d 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -167,7 +159,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] 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 d3555f1bf2a5..45be455f447f 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,22 +17,14 @@ 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 -# Optional: OpenTelemetry tracing capabilities for grpc channel injection -# Note: ClientInterceptor was added in google-api-core 2.36.0+; fallback for older versions -try: +if TYPE_CHECKING: + # 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] -except ImportError: # pragma: NO COVER - ClientInterceptor = Union[ # type: ignore[misc,assignment] - grpc.UnaryUnaryClientInterceptor, - grpc.UnaryStreamClientInterceptor, - grpc.StreamUnaryClientInterceptor, - grpc.StreamStreamClientInterceptor, - ] from google.api_core import operations_v1 from google.api_core import gapic_v1 import google.auth # type: ignore @@ -153,7 +145,7 @@ def __init__(self, *, interceptors: Optional[ Sequence[ Union[ - ClientInterceptor, + "ClientInterceptor", Callable[[grpc.Channel], grpc.Channel], ] ] From 66a6f0ebe2fbf734880cb33677c9016a297828d1 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 15 Sep 2026 11:49:04 -0400 Subject: [PATCH 27/27] test(gapic): synchronize NO COVER pragma in golden gRPC transports Align if TYPE_CHECKING: in golden gRPC transport files with # pragma: NO COVER to match grpc.py.j2 template output. --- .../cloud/asset_v1/services/asset_service/transports/grpc.py | 2 +- .../credentials_v1/services/iam_credentials/transports/grpc.py | 2 +- .../cloud/eventarc_v1/services/eventarc/transports/grpc.py | 2 +- .../logging_v2/services/config_service_v2/transports/grpc.py | 2 +- .../logging_v2/services/logging_service_v2/transports/grpc.py | 2 +- .../logging_v2/services/metrics_service_v2/transports/grpc.py | 2 +- .../logging_v2/services/config_service_v2/transports/grpc.py | 2 +- .../logging_v2/services/logging_service_v2/transports/grpc.py | 2 +- .../logging_v2/services/metrics_service_v2/transports/grpc.py | 2 +- .../cloud/redis_v1/services/cloud_redis/transports/grpc.py | 2 +- .../cloud/redis_v1/services/cloud_redis/transports/grpc.py | 2 +- .../services/storage_batch_operations/transports/grpc.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) 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 1d6580b80ed4..267e843bd30b 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 b29bf163cc58..afb217d40e8e 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import gapic_v1 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 0e18c592e1ac..37492a456a3c 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 5b427443797b..a984a2b148fa 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 4e2e4196e99b..b84d55ab637e 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import gapic_v1 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 1dc6897f9301..68a021a84ba8 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import gapic_v1 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 5b427443797b..a984a2b148fa 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 4e2e4196e99b..b84d55ab637e 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import gapic_v1 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 1dc6897f9301..68a021a84ba8 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import gapic_v1 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 e72d49a74546..df9d22081945 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 5658d2c5826d..448117af19b0 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1 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 45be455f447f..6af36576dacc 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 @@ -22,7 +22,7 @@ import grpc # type: ignore from google.api_core import grpc_helpers -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: NO COVER # ClientInterceptor was added in google-api-core 2.36.0+; ignore attribute-defined for older api-core versions during type checking from google.api_core.grpc_helpers import ClientInterceptor # type: ignore[attr-defined] from google.api_core import operations_v1