Skip to content

Commit 7c2755a

Browse files
committed
interop: add OpenTelemetry tracing support for C++ and Python interop servers
1 parent 0bebef3 commit 7c2755a

7 files changed

Lines changed: 353 additions & 6 deletions

File tree

src/python/grpcio_tests/tests/interop/BUILD.bazel

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ py_library(
2525
],
2626
)
2727

28+
py_library(
29+
name = "otel_interop_helper",
30+
srcs = ["otel_interop_helper.py"],
31+
deps = [
32+
"//src/proto/grpc/testing:py_test_proto",
33+
"//src/python/grpcio/grpc:grpcio",
34+
"@opentelemetry_proto//:common_proto_py",
35+
"@opentelemetry_proto//:trace_proto_py",
36+
"@opentelemetry_proto//:trace_service_grpc_py",
37+
"@opentelemetry_proto//:trace_service_proto_py",
38+
requirement("opentelemetry-api"),
39+
requirement("opentelemetry-sdk"),
40+
],
41+
)
42+
2843
py_library(
2944
name = "client_lib",
3045
srcs = ["client.py"],
@@ -34,6 +49,7 @@ py_library(
3449
],
3550
deps = [
3651
":methods",
52+
":otel_interop_helper",
3753
":resources",
3854
"//src/proto/grpc/testing:py_test_proto",
3955
"//src/proto/grpc/testing:test_py_pb2_grpc",
@@ -42,6 +58,8 @@ py_library(
4258
"//src/python/grpcio_tests/tests:bazel_namespace_package_hack",
4359
requirement("absl-py"),
4460
requirement("google-auth"),
61+
requirement("opentelemetry-api"),
62+
requirement("opentelemetry-sdk"),
4563
],
4664
)
4765

@@ -109,6 +127,7 @@ py_library(
109127
"../../../../",
110128
],
111129
deps = [
130+
":otel_interop_helper",
112131
":resources",
113132
":service",
114133
"//src/proto/grpc/testing:py_test_proto",
@@ -118,6 +137,8 @@ py_library(
118137
"//src/python/grpcio_tests/tests:bazel_namespace_package_hack",
119138
"//src/python/grpcio_tests/tests/unit:test_common",
120139
requirement("absl-py"),
140+
requirement("opentelemetry-api"),
141+
requirement("opentelemetry-sdk"),
121142
],
122143
)
123144

src/python/grpcio_tests/tests/interop/client.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,56 @@ def get_secure_channel_parameters(args):
206206
return channel_credentials, channel_opts
207207

208208

209+
class _OTelClientInterceptor(grpc.UnaryUnaryClientInterceptor):
210+
def __init__(self, tracer):
211+
self._tracer = tracer
212+
213+
def intercept_unary_unary(self, continuation, client_call_details, request):
214+
from grpc._interceptor import _ClientCallDetails
215+
from opentelemetry import trace
216+
from tests.interop import otel_interop_helper
217+
218+
method = client_call_details.method
219+
full_method = method.lstrip("/")
220+
sent_span_name = f"Sent.{full_method}"
221+
attempt_span_name = f"Attempt.{full_method}"
222+
223+
sent_span = self._tracer.start_span(sent_span_name, kind=trace.SpanKind.CLIENT)
224+
sent_ctx = trace.set_span_in_context(sent_span)
225+
attempt_span = self._tracer.start_span(
226+
attempt_span_name, kind=trace.SpanKind.CLIENT, context=sent_ctx
227+
)
228+
attempt_span.set_attribute("previous-rpc-attempts", 0)
229+
attempt_span.set_attribute("transparent-retry", False)
230+
attempt_span.add_event("Outbound message")
231+
232+
trace_bin_bytes = otel_interop_helper.pack_grpc_trace_bin(
233+
attempt_span.get_span_context().trace_id,
234+
attempt_span.get_span_context().span_id,
235+
)
236+
237+
metadata = list(client_call_details.metadata or [])
238+
metadata.append(("grpc-trace-bin", trace_bin_bytes))
239+
240+
new_details = _ClientCallDetails(
241+
method=client_call_details.method,
242+
timeout=client_call_details.timeout,
243+
metadata=metadata,
244+
credentials=client_call_details.credentials,
245+
wait_for_ready=client_call_details.wait_for_ready,
246+
compression=client_call_details.compression,
247+
)
248+
249+
try:
250+
response = continuation(new_details, request)
251+
attempt_span.add_event("Inbound message")
252+
return response
253+
finally:
254+
attempt_span.end()
255+
sent_span.end()
256+
otel_interop_helper.flush_tracer_provider()
257+
258+
209259
def _create_channel(args):
210260
target = "{}:{}".format(args.server_host, args.server_port)
211261

@@ -215,9 +265,16 @@ def _create_channel(args):
215265
or args.custom_credentials_type is not None
216266
):
217267
channel_credentials, options = get_secure_channel_parameters(args)
218-
return grpc.secure_channel(target, channel_credentials, options)
268+
channel = grpc.secure_channel(target, channel_credentials, options)
219269
else:
220-
return grpc.insecure_channel(target)
270+
channel = grpc.insecure_channel(target)
271+
272+
if args.enable_opentelemetry:
273+
from tests.interop import otel_interop_helper
274+
provider, tracer = otel_interop_helper.init_tracer_provider()
275+
channel = grpc.intercept_channel(channel, _OTelClientInterceptor(tracer))
276+
277+
return channel
221278

222279

223280
def create_stub(channel, args):
@@ -238,12 +295,14 @@ def _test_case_from_arg(test_case_arg):
238295
def test_interoperability(args):
239296
if args.enable_opentelemetry:
240297
import grpc_observability
298+
from tests.interop import otel_interop_helper
241299

242300
with grpc_observability.OpenTelemetryPlugin():
243301
channel = _create_channel(args)
244302
stub = create_stub(channel, args)
245303
test_case = _test_case_from_arg(args.test_case)
246304
test_case.test_interoperability(stub, args)
305+
otel_interop_helper.flush_tracer_provider()
247306
else:
248307
channel = _create_channel(args)
249308
stub = create_stub(channel, args)
@@ -253,3 +312,4 @@ def test_interoperability(args):
253312

254313
if __name__ == "__main__":
255314
app.run(test_interoperability, flags_parser=parse_interop_client_args)
315+
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Copyright 2026 gRPC authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""OpenTelemetry Tracing Interop Helper for Python gRPC Interop Client/Server."""
15+
16+
import os
17+
from typing import Optional, Tuple
18+
19+
import grpc
20+
from opentelemetry import trace
21+
from opentelemetry.proto.collector.trace.v1 import trace_service_pb2, trace_service_pb2_grpc
22+
from opentelemetry.proto.common.v1 import common_pb2
23+
from opentelemetry.proto.trace.v1 import trace_pb2
24+
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
25+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
26+
27+
28+
class OTLPSpanExporter(SpanExporter):
29+
"""Exporter that sends OTLP spans to OTLP Collector over gRPC."""
30+
31+
def __init__(self, endpoint: str):
32+
if endpoint.startswith("http://"):
33+
endpoint = endpoint[7:]
34+
elif endpoint.startswith("https://"):
35+
endpoint = endpoint[8:]
36+
self._channel = grpc.insecure_channel(endpoint)
37+
self._stub = trace_service_pb2_grpc.TraceServiceStub(self._channel)
38+
39+
def export(self, spans: Tuple[ReadableSpan, ...]) -> None:
40+
if not spans:
41+
return
42+
43+
otlp_spans = []
44+
for span in spans:
45+
ctx = span.context
46+
parent_ctx = span.parent
47+
48+
trace_id_bytes = ctx.trace_id.to_bytes(16, "big")
49+
span_id_bytes = ctx.span_id.to_bytes(8, "big")
50+
parent_span_id_bytes = (
51+
parent_ctx.span_id.to_bytes(8, "big")
52+
if parent_ctx and parent_ctx.span_id
53+
else b""
54+
)
55+
56+
proto_attributes = []
57+
if span.attributes:
58+
for k, v in span.attributes.items():
59+
kv = common_pb2.KeyValue(key=k)
60+
if isinstance(v, bool):
61+
kv.value.bool_value = v
62+
elif isinstance(v, int):
63+
kv.value.int_value = v
64+
elif isinstance(v, float):
65+
kv.value.double_value = v
66+
else:
67+
kv.value.string_value = str(v)
68+
proto_attributes.append(kv)
69+
70+
proto_events = []
71+
if span.events:
72+
for event in span.events:
73+
e = trace_pb2.Span.Event(
74+
name=event.name,
75+
time_unix_nano=event.timestamp,
76+
)
77+
proto_events.append(e)
78+
79+
kind = (
80+
trace_pb2.Span.SpanKind.SPAN_KIND_CLIENT
81+
if span.kind == trace.SpanKind.CLIENT
82+
else trace_pb2.Span.SpanKind.SPAN_KIND_SERVER
83+
)
84+
85+
proto_span = trace_pb2.Span(
86+
trace_id=trace_id_bytes,
87+
span_id=span_id_bytes,
88+
parent_span_id=parent_span_id_bytes,
89+
name=span.name,
90+
kind=kind,
91+
start_time_unix_nano=span.start_time,
92+
end_time_unix_nano=span.end_time,
93+
attributes=proto_attributes,
94+
events=proto_events,
95+
)
96+
otlp_spans.append(proto_span)
97+
98+
scope_spans = trace_pb2.ScopeSpans(spans=otlp_spans)
99+
resource_spans = trace_pb2.ResourceSpans(scope_spans=[scope_spans])
100+
request = trace_service_pb2.ExportTraceServiceRequest(
101+
resource_spans=[resource_spans]
102+
)
103+
104+
try:
105+
self._stub.Export(request, timeout=5)
106+
except Exception:
107+
pass
108+
109+
def shutdown(self) -> None:
110+
self._channel.close()
111+
112+
def force_flush(self, timeout_millis: int = 30000) -> bool:
113+
return True
114+
115+
116+
_GLOBAL_PROVIDER: Optional[TracerProvider] = None
117+
118+
119+
def init_tracer_provider() -> Tuple[TracerProvider, trace.Tracer]:
120+
global _GLOBAL_PROVIDER
121+
if _GLOBAL_PROVIDER is None:
122+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
123+
exporter = OTLPSpanExporter(endpoint)
124+
processor = SimpleSpanProcessor(exporter)
125+
_GLOBAL_PROVIDER = TracerProvider()
126+
_GLOBAL_PROVIDER.add_span_processor(processor)
127+
trace.set_tracer_provider(_GLOBAL_PROVIDER)
128+
tracer = trace.get_tracer("grpc-python-interop")
129+
return _GLOBAL_PROVIDER, tracer
130+
131+
132+
def flush_tracer_provider():
133+
global _GLOBAL_PROVIDER
134+
if _GLOBAL_PROVIDER:
135+
_GLOBAL_PROVIDER.force_flush()
136+
137+
138+
def pack_grpc_trace_bin(
139+
trace_id_int: int, span_id_int: int, is_sampled: bool = True
140+
) -> bytes:
141+
trace_id_bytes = trace_id_int.to_bytes(16, "big")
142+
span_id_bytes = span_id_int.to_bytes(8, "big")
143+
options = 1 if is_sampled else 0
144+
return b"\x00\x00" + trace_id_bytes + b"\x01" + span_id_bytes + b"\x02" + bytes([options])
145+
146+
147+
def unpack_grpc_trace_bin(
148+
header_bytes: bytes,
149+
) -> Tuple[Optional[int], Optional[int], bool]:
150+
if len(header_bytes) >= 29 and header_bytes[0] == 0:
151+
trace_id_int = int.from_bytes(header_bytes[2:18], "big")
152+
span_id_int = int.from_bytes(header_bytes[19:27], "big")
153+
is_sampled = bool(header_bytes[28] & 1)
154+
return trace_id_int, span_id_int, is_sampled
155+
return None, None, False
156+
157+
158+
def parse_traceparent(
159+
header_str: str,
160+
) -> Tuple[Optional[int], Optional[int], bool]:
161+
parts = header_str.split("-")
162+
if len(parts) >= 4 and parts[0] == "00":
163+
try:
164+
trace_id_int = int(parts[1], 16)
165+
span_id_int = int(parts[2], 16)
166+
is_sampled = (int(parts[3], 16) & 1) != 0
167+
return trace_id_int, span_id_int, is_sampled
168+
except ValueError:
169+
pass
170+
return None, None, False
171+
172+
173+
class OTelServerInterceptor(grpc.ServerInterceptor):
174+
"""Server interceptor to extract trace context and create server Recv span."""
175+
176+
def __init__(self, tracer: trace.Tracer):
177+
self._tracer = tracer
178+
179+
def intercept_service(self, continuation, handler_call_details):
180+
print(f"DEBUG_SERVER_METADATA: {handler_call_details.invocation_metadata}", flush=True)
181+
trace_bin_header = None
182+
traceparent_header = None
183+
for k, v in handler_call_details.invocation_metadata:
184+
k_str = k.decode("ascii", errors="ignore") if isinstance(k, bytes) else str(k)
185+
if k_str.lower() == "grpc-trace-bin":
186+
trace_bin_header = v
187+
elif k_str.lower() == "traceparent":
188+
traceparent_header = v if isinstance(v, str) else v.decode("latin1")
189+
190+
parent_ctx = None
191+
trace_id, parent_span_id, is_sampled = None, None, False
192+
193+
if trace_bin_header:
194+
if isinstance(trace_bin_header, str):
195+
trace_bin_header = trace_bin_header.encode("latin1")
196+
trace_id, parent_span_id, is_sampled = unpack_grpc_trace_bin(trace_bin_header)
197+
elif traceparent_header:
198+
trace_id, parent_span_id, is_sampled = parse_traceparent(traceparent_header)
199+
200+
if trace_id and parent_span_id:
201+
parent_ctx = trace.SpanContext(
202+
trace_id=trace_id,
203+
span_id=parent_span_id,
204+
is_remote=True,
205+
trace_flags=trace.TraceFlags(1 if is_sampled else 0),
206+
)
207+
208+
method = handler_call_details.method
209+
full_method = method.lstrip("/")
210+
span_name = f"Recv.{full_method}"
211+
212+
if parent_ctx:
213+
ctx = trace.set_span_in_context(trace.NonRecordingSpan(parent_ctx))
214+
server_span = self._tracer.start_span(
215+
span_name, kind=trace.SpanKind.SERVER, context=ctx
216+
)
217+
else:
218+
server_span = self._tracer.start_span(
219+
span_name, kind=trace.SpanKind.SERVER
220+
)
221+
222+
server_span.add_event("Inbound message")
223+
224+
handler = continuation(handler_call_details)
225+
226+
if handler is None:
227+
server_span.end()
228+
return None
229+
230+
if handler.unary_unary:
231+
orig_func = handler.unary_unary
232+
def wrapper(request, context):
233+
try:
234+
res = orig_func(request, context)
235+
server_span.add_event("Outbound message")
236+
return res
237+
finally:
238+
server_span.end()
239+
flush_tracer_provider()
240+
return grpc.unary_unary_rpc_method_handler(
241+
wrapper,
242+
request_deserializer=handler.request_deserializer,
243+
response_serializer=handler.response_serializer,
244+
)
245+
246+
return handler

0 commit comments

Comments
 (0)