|
| 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