Skip to content

Commit 26e8fc1

Browse files
committed
feat(api-core): implement OtelUnaryClientInterceptor and feature gating
Refactored OtelSpanEnricher to OtelUnaryClientInterceptor to act as a span creator (Pure API approach). Integrated feature gating helpers to control tracing. Added dynamic attribute extraction from gRPC metadata. Cleaned up dead code and added samples.
1 parent 39cdcd5 commit 26e8fc1

7 files changed

Lines changed: 278 additions & 397 deletions

File tree

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
1-
from .options import (
2-
clear_test_env_overrides,
3-
is_signal_enabled,
4-
set_test_env_override,
5-
)
6-
71
try:
82
# Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace.
9-
from .tracing import OtelSpanEnricher # noqa: F401
3+
from .tracing import OtelUnaryClientInterceptor # noqa: F401
104

115
__all__ = [
12-
"is_signal_enabled",
13-
"set_test_env_override",
14-
"clear_test_env_overrides",
15-
"OtelSpanEnricher",
6+
"OtelUnaryClientInterceptor",
167
]
178
except ImportError:
18-
__all__ = [
19-
"is_signal_enabled",
20-
"set_test_env_override",
21-
"clear_test_env_overrides",
22-
]
9+
__all__ = []

packages/google-api-core/google/api_core/observability/options.py

Lines changed: 0 additions & 110 deletions
This file was deleted.

packages/google-api-core/google/api_core/observability/tracing.py

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,56 +20,77 @@
2020
from opentelemetry import trace
2121

2222

23-
class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor):
24-
"""A gRPC client interceptor that enriches the active OpenTelemetry span.
23+
class OtelUnaryClientInterceptor(grpc.UnaryUnaryClientInterceptor):
24+
"""A gRPC client interceptor that creates OpenTelemetry spans for outgoing requests.
2525
26-
This interceptor relies on the standard OpenTelemetry gRPC instrumentor
27-
to create the baseline span. It runs in the interceptor chain to inject
28-
additional Google Cloud specific domain attributes.
26+
This interceptor explicitly creates a standard SpanKind.CLIENT span for each network attempt
27+
and enriches it with standard Google Cloud attributes.
2928
"""
3029

3130
def __init__(
3231
self,
3332
static_attributes: Optional[Dict[str, Any]] = None,
34-
attribute_extractor: Optional[
35-
Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]]
36-
] = None,
3733
):
38-
"""Initializes the OtelSpanEnricher.
34+
"""Initializes the OtelUnaryClientInterceptor.
3935
4036
Args:
4137
static_attributes: Standard static attributes to attach to every span.
4238
E.g. {"gcp.client.repo": "googleapis/google-cloud-python"}
43-
attribute_extractor: A callable that extracts dynamic attributes from
44-
the request and client call details.
4539
"""
4640
self._static_attributes = static_attributes or {}
47-
self._attribute_extractor = attribute_extractor
4841

4942
def intercept_unary_unary(
5043
self,
5144
continuation: Callable[[grpc.ClientCallDetails, Any], Any],
5245
client_call_details: grpc.ClientCallDetails,
5346
request: Any,
5447
) -> Any:
55-
span = trace.get_current_span()
56-
57-
if span.is_recording():
58-
# Inject static attributes
59-
for key, val in self._static_attributes.items():
60-
span.set_attribute(key, val)
61-
62-
# Extract and inject dynamic attributes
63-
if self._attribute_extractor:
64-
try:
65-
dynamic_attrs = self._attribute_extractor(
66-
request, client_call_details
67-
)
68-
for key, val in dynamic_attrs.items():
69-
if val is not None:
70-
span.set_attribute(key, val)
71-
except Exception:
72-
# Prevent custom extractor exceptions from failing the RPC
73-
pass
74-
75-
return continuation(client_call_details, request)
48+
from google.api_core._feature_gating_helpers import resolve_feature_flags
49+
50+
# For now, we only check environment variables as we don't have access to ClientOptions here.
51+
# To support programmatic configuration, we would need to pass it during Client
52+
# initialization.
53+
# TODO: we need to refactor resolve_feature_flags to allows feature_key to be optional.
54+
enabled = resolve_feature_flags(
55+
env_var="GOOGLE_CLOUD_PYTHON_TRACING_ENABLED",
56+
feature_key="tracer_provider",
57+
)
58+
59+
if not enabled:
60+
return continuation(client_call_details, request)
61+
62+
tracer = trace.get_tracer(__name__)
63+
64+
# Determine span name (e.g., from client_call_details.method)
65+
span_name = client_call_details.method
66+
67+
with tracer.start_as_current_span(
68+
span_name, kind=trace.SpanKind.CLIENT
69+
) as span:
70+
if span.is_recording():
71+
# Inject static attributes
72+
for key, val in self._static_attributes.items():
73+
span.set_attribute(key, val)
74+
75+
# Extract dynamic attributes from metadata
76+
for key, value in client_call_details.metadata:
77+
if key == "x-goog-request-params":
78+
try:
79+
# x-goog-request-params is urlencoded string of key=value pairs separated by &
80+
params = dict(
81+
p.split("=") for p in value.split("&") if "=" in p
82+
)
83+
84+
# Standard resource identifiers are usually in 'name' or 'parent'
85+
resource_id = params.get("name") or params.get("parent")
86+
if resource_id:
87+
span.set_attribute(
88+
"gcp.resource.destination.id", resource_id
89+
)
90+
except Exception:
91+
# Fail open if parsing fails to avoid breaking the request
92+
pass
93+
94+
span.set_attribute("rpc.system.name", "grpc")
95+
96+
return continuation(client_call_details, request)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
3+
from google.cloud import translate_v3
4+
from google.cloud.translate_v3.types import translation_service
5+
6+
# 🚀 1. ACTIVATE MONKEY PATCHING (Auto-Instrumentation)
7+
# This reaches into the gRPC library and wraps standard functions dynamically.
8+
from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient
9+
10+
GrpcInstrumentorClient().instrument()
11+
print("✅ gRPC Client Auto-Instrumentation activated!")
12+
13+
# 2. Standard OTel SDK Setup (Same as before, so we can see the console output)
14+
from opentelemetry import trace
15+
from opentelemetry.sdk.trace import TracerProvider
16+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
17+
18+
print("Initializing TracerProvider...")
19+
provider = TracerProvider()
20+
exporter = ConsoleSpanExporter()
21+
provider.add_span_processor(SimpleSpanProcessor(exporter))
22+
trace.set_tracer_provider(provider)
23+
print("TracerProvider initialized.")
24+
25+
# 3. Instantiate Client (Standard GAPIC, NO manual instrumentation used here)
26+
print("Instantiating TranslationServiceClient...")
27+
client = translate_v3.TranslationServiceClient()
28+
print("TranslationServiceClient instantiated.")
29+
30+
# 4. Create Request
31+
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian")
32+
parent = f"projects/{project_id}/locations/global"
33+
34+
request = translation_service.TranslateTextRequest(
35+
contents=["Hello, world!", "OpenTelemetry is braw!"],
36+
target_language_code="es",
37+
source_language_code="en",
38+
model=f"{parent}/models/general/nmt",
39+
mime_type="text/plain",
40+
parent=parent,
41+
)
42+
43+
# 5. Call API
44+
print("Sending translate request...")
45+
try:
46+
response = client.translate_text(request)
47+
print("Translation Response received.")
48+
print(f"Translated text: {response.translations[0].translated_text}")
49+
except Exception as e:
50+
print(f"API Call failed: {e}")
51+
52+
print("Done. Check console output for traces.")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
3+
from google.cloud import translate_v3
4+
from google.cloud.translate_v3.types import translation_service
5+
from opentelemetry import trace
6+
from opentelemetry.sdk.trace import TracerProvider
7+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
8+
9+
# 1. Setup OTel with Console Exporter
10+
provider = TracerProvider()
11+
exporter = ConsoleSpanExporter()
12+
provider.add_span_processor(SimpleSpanProcessor(exporter))
13+
trace.set_tracer_provider(provider)
14+
15+
# 2. Instantiate Client
16+
# Using standard Application Default Credentials (ADC).
17+
client = translate_v3.TranslationServiceClient()
18+
19+
# 3. Create Request
20+
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "callovian")
21+
parent = f"projects/{project_id}/locations/global"
22+
23+
request = translation_service.TranslateTextRequest(
24+
contents=["Hello, world!", "OpenTelemetry is braw!"],
25+
target_language_code="es",
26+
source_language_code="en",
27+
model=f"{parent}/models/general/nmt",
28+
mime_type="text/plain",
29+
parent=parent,
30+
)
31+
32+
# 4. Call API
33+
print("Sending translate request...")
34+
try:
35+
response = client.translate_text(request)
36+
print("Translation Response received.")
37+
print(f"Translated text: {response.translations[0].translated_text}")
38+
except Exception as e:
39+
print(f"API Call failed (expected if no real credentials): {e}")
40+
41+
print("Done. Check console output for traces.")

0 commit comments

Comments
 (0)