"""HTTP and Convex spans with request paths, but no query/header/payload capture.""" import asyncio import os from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased from opentelemetry.trace import SpanKind, Status, StatusCode from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from quart import g, request _PROPAGATOR = TraceContextTextMapPropagator() class RequestTracingMiddleware: def __init__(self, app, tracer): self.app = app self.tracer = tracer async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) carrier = { key.decode("latin1"): value.decode("latin1") for key, value in scope.get("headers", []) if key.lower() in (b"traceparent", b"tracestate") } method = scope.get("method", "HTTP") with self.tracer.start_as_current_span( f"HTTP {method}", context=_PROPAGATOR.extract(carrier), kind=SpanKind.SERVER, attributes={ "http.request.method": method, # ASGI path excludes the query string. Capture it before routing so # unmatched requests and early middleware responses remain identifiable. "url.path": scope.get("path", ""), }, record_exception=False, set_status_on_exception=False, ) as span: async def traced_send(message): if message["type"] == "http.response.start": status = message["status"] span.set_attribute("http.response.status_code", status) if status >= 500: span.set_status(Status(StatusCode.ERROR)) trace_id = f"{span.get_span_context().trace_id:032x}" headers = [(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-trace-id"] message = {**message, "headers": [*headers, (b"x-trace-id", trace_id.encode("ascii"))]} await send(message) try: return await self.app(scope, receive, traced_send) except BaseException as error: span.set_attribute("error.type", type(error).__name__) span.set_status(Status(StatusCode.ERROR)) raise def register_request_tracing(app, *, provider=None): """Disabled without an OTLP endpoint. Inject a provider for isolated tests.""" if provider is None: if not (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")): return None from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter sample_rate = float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "0.1")) if not 0 <= sample_rate <= 1: raise ValueError("OTEL_TRACES_SAMPLER_ARG must be between 0 and 1") provider = TracerProvider( resource=Resource.create({"service.name": os.getenv("OTEL_SERVICE_NAME") or os.getenv("SERVICE_NAME", "nanoshare")}), sampler=ParentBased(TraceIdRatioBased(sample_rate)), ) provider.add_span_processor(BatchSpanProcessor( OTLPSpanExporter(timeout=2), max_queue_size=2048, max_export_batch_size=256, schedule_delay_millis=1000, )) trace.set_tracer_provider(provider) @app.after_serving async def shutdown_tracing(): # SDK uses a bounded background queue, never an export on the request path. await asyncio.to_thread(provider.shutdown) app.asgi_app = RequestTracingMiddleware(app.asgi_app, provider.get_tracer(__name__)) @app.before_request async def correlate_request(): span = trace.get_current_span() context = span.get_span_context() event = getattr(g, "wide_event", None) if event is not None and context.is_valid: event["trace_id"] = f"{context.trace_id:032x}" request_id = event.get("request_id") if request_id: span.set_attribute("request.id", request_id) # Keep span names low-cardinality; the actual path is in url.path. if request.url_rule is not None: route = request.url_rule.rule span.update_name(f"{request.method} {route}") span.set_attribute("http.route", route) return provider