Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
try:
# Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace.
from .tracing import OtelUnaryClientInterceptor # noqa: F401

__all__ = [
"OtelUnaryClientInterceptor",
]
except ImportError:
__all__ = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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
#
# http://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.

"""OpenTelemetry Tracing Enrichment Interceptors."""

from typing import Any, Callable, Dict, Optional

import grpc
from opentelemetry import trace


class OtelUnaryClientInterceptor(grpc.UnaryUnaryClientInterceptor):
"""A gRPC client interceptor that creates OpenTelemetry spans for outgoing requests.

This interceptor explicitly creates a standard SpanKind.CLIENT span for each network attempt
and enriches it with standard Google Cloud attributes.
"""

def __init__(
self,
static_attributes: Optional[Dict[str, Any]] = None,
):
"""Initializes the OtelUnaryClientInterceptor.

Args:
static_attributes: Standard static attributes to attach to every span.
E.g. {"gcp.client.repo": "googleapis/google-cloud-python"}
"""
self._static_attributes = static_attributes or {}

def intercept_unary_unary(
self,
continuation: Callable[[grpc.ClientCallDetails, Any], Any],
client_call_details: grpc.ClientCallDetails,
request: Any,
) -> Any:
from google.api_core._feature_gating_helpers import resolve_feature_flags

# For now, we only check environment variables as we don't have access to ClientOptions here.
# To support programmatic configuration, we would need to pass it during Client
# initialization.
# TODO: we need to refactor resolve_feature_flags to allows feature_key to be optional.
enabled = resolve_feature_flags(
env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
feature_key="tracer_provider",
)

if not enabled:
return continuation(client_call_details, request)

tracer = trace.get_tracer(__name__)

# Determine span name (e.g., from client_call_details.method)
span_name = client_call_details.method

with tracer.start_as_current_span(
span_name, kind=trace.SpanKind.CLIENT
) as span:
if span.is_recording():
# Inject static attributes
for key, val in self._static_attributes.items():
span.set_attribute(key, val)

# Extract dynamic attributes from metadata
for key, value in client_call_details.metadata:
if key == "x-goog-request-params":
try:
# x-goog-request-params is urlencoded string of key=value pairs separated by &
params = dict(
p.split("=") for p in value.split("&") if "=" in p
)

# Standard resource identifiers are usually in 'name' or 'parent'
resource_id = params.get("name") or params.get("parent")
if resource_id:
span.set_attribute(
"gcp.resource.destination.id", resource_id
)
Comment on lines +76 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Issues Identified:

  1. Potential Crash on Null Metadata: client_call_details.metadata can be None if no metadata is provided for the gRPC call. Iterating over it directly will raise a TypeError and crash the RPC call.
  2. Handling of Bytes Metadata Values: Depending on the gRPC transport and environment, metadata values can be passed as bytes instead of str. If value is bytes, calling .split("&") with a string argument will raise a TypeError and silently fail open.
  3. URL-Decoding of Resource IDs: The values in x-goog-request-params are URL-encoded. Standard resource identifiers (e.g., containing slashes or special characters) should be URL-decoded using urllib.parse.unquote before being set as the span attribute.
  4. Broad Exception Handling: Avoid broad except Exception: blocks that silently pass or ignore errors. Instead, log the exception to aid in debugging and prevent masking underlying issues.

Recommendation:

Update the metadata extraction loop to handle None metadata gracefully, decode bytes values to str, URL-decode the extracted resource ID, and log any parsing exceptions instead of silently passing.

Suggested change
for key, value in client_call_details.metadata:
if key == "x-goog-request-params":
try:
# x-goog-request-params is urlencoded string of key=value pairs separated by &
params = dict(
p.split("=") for p in value.split("&") if "=" in p
)
# Standard resource identifiers are usually in 'name' or 'parent'
resource_id = params.get("name") or params.get("parent")
if resource_id:
span.set_attribute(
"gcp.resource.destination.id", resource_id
)
for key, value in (client_call_details.metadata or []):
if key == "x-goog-request-params":
try:
# x-goog-request-params is urlencoded string of key=value pairs separated by &
val_str = value.decode("utf-8") if isinstance(value, bytes) else value
params = dict(
p.split("=") for p in val_str.split("&") if "=" in p
)
# Standard resource identifiers are usually in 'name' or 'parent'
resource_id = params.get("name") or params.get("parent")
if resource_id:
from urllib.parse import unquote
span.set_attribute(
"gcp.resource.destination.id", unquote(resource_id)
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning("Failed to parse x-goog-request-params: %s", e)
References
  1. Avoid broad except Exception: blocks that silently return None or pass. Instead, log the exception to aid in debugging and prevent masking underlying issues.

except Exception:
# Fail open if parsing fails to avoid breaking the request
pass

span.set_attribute("rpc.system.name", "grpc")

return continuation(client_call_details, request)
3 changes: 3 additions & 0 deletions packages/google-api-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ dependencies = [
"proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'",
"google-auth >= 2.14.1, < 3.0.0",
"requests >= 2.33.0, < 3.0.0",
"opentelemetry-api >= 1.27.0, < 2.0.0",
]
dynamic = ["version"]

Expand Down Expand Up @@ -94,4 +95,6 @@ filterwarnings = [
"ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning",
# Remove once https://github.com/grpc/grpc/issues/35086 is fixed (and version newer than 1.60.0 is published)
"ignore:There is no current event loop:DeprecationWarning",
# Ignore external OpenTelemetry/importlib.metadata SelectableGroups warning
"ignore:.*SelectableGroups dict interface is deprecated:DeprecationWarning",
]
1 change: 1 addition & 0 deletions packages/google-api-core/testing/constraints-3.10.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ requests==2.33.0
grpcio==1.41.0
grpcio-status==1.41.0
proto-plus==1.24.0
opentelemetry-api==1.27.0
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ grpcio==1.41.0
grpcio-status==1.41.0
proto-plus==1.24.0
aiohttp==3.13.4
opentelemetry-api==1.27.0
53 changes: 53 additions & 0 deletions packages/google-api-core/tests/unit/observability/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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
#
# http://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.

"""Tests for google.api_core.observability.__init__."""

import importlib
import sys


def test_init_exports():
import google.api_core.observability

# Check if dependencies are available
try:
import grpc # noqa: F401
import opentelemetry.trace # noqa: F401

has_deps = True
except ImportError:
has_deps = False

if has_deps:
assert "OtelUnaryClientInterceptor" in google.api_core.observability.__all__
else:
assert google.api_core.observability.__all__ == []


def test_init_import_error_forced(monkeypatch):
"""Verifies behavior when tracing module fails to import, even if deps are present."""
import google.api_core.observability

# Poison the tracing module
monkeypatch.setitem(sys.modules, "google.api_core.observability.tracing", None)

# Reload observability, it should fail to import tracing and trigger except block
importlib.reload(google.api_core.observability)

assert google.api_core.observability.__all__ == []

# Clean up
monkeypatch.undo()
importlib.reload(google.api_core.observability)
Loading
Loading