How to verify OpenTelemetry context propagation over HTTP

OpenTelemetry context propagation over HTTP proves that a request keeps one trace as it moves from one service to another. That proof matters when a distributed trace breaks at an internal HTTP call and a downstream span appears as a new root instead of a child of the upstream request.

A Python smoke test can run two local Flask services and instrument both the Flask server spans and the Requests client call. Service A receives /call-b, calls service B at /work, and the instrumentation injects the W3C Trace Context traceparent header into the outbound HTTP request.

A passing run has two pieces of evidence. The JSON response reports the same trace ID from both services, and the service log shows the service B server span parent ID equal to the parent ID carried in the traceparent header.

Steps to verify OpenTelemetry context propagation over HTTP:

  1. Create a Python virtual environment for the propagation test.
    $ python3 -m venv context-propagation-demo
  2. Activate the virtual environment.
    $ . context-propagation-demo/bin/activate
  3. Install Flask, Requests, and the OpenTelemetry instrumentation packages.
    $ python -m pip install flask requests opentelemetry-sdk opentelemetry-instrumentation-flask opentelemetry-instrumentation-requests
    ##### snipped #####
    Successfully installed flask-3.1.3 opentelemetry-api-1.42.1 opentelemetry-instrumentation-0.63b1 opentelemetry-instrumentation-flask-0.63b1 opentelemetry-instrumentation-requests-0.63b1 opentelemetry-sdk-1.42.1 requests-2.34.2

    opentelemetry-instrumentation-flask creates server spans for incoming HTTP requests, and opentelemetry-instrumentation-requests creates client spans and injects the outgoing trace context.

  4. Create the two-service propagation demo.
    propagation_demo.py
    import json
    import threading
     
    import requests
    from flask import Flask, Response, request
    from opentelemetry import trace
    from opentelemetry.instrumentation.flask import FlaskInstrumentor
    from opentelemetry.instrumentation.requests import RequestsInstrumentor
    from opentelemetry.sdk.resources import Resource
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
    from werkzeug.serving import make_server
     
     
    resource = Resource.create({"service.name": "http-propagation-demo"})
    provider = TracerProvider(resource=resource)
    provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
    trace.set_tracer_provider(provider)
     
    app_a = Flask("service-a")
    app_b = Flask("service-b")
    FlaskInstrumentor().instrument_app(app_a)
    FlaskInstrumentor().instrument_app(app_b)
    RequestsInstrumentor().instrument()
     
     
    def current_span_ids():
        span = trace.get_current_span()
        context = span.get_span_context()
        parent = getattr(span, "parent", None)
        return {
            "trace_id": f"{context.trace_id:032x}",
            "span_id": f"{context.span_id:016x}",
            "parent_span_id": f"{parent.span_id:016x}" if parent else None,
        }
     
     
    def json_response(payload):
        return Response(
            json.dumps(payload, indent=2, sort_keys=True) + "\n",
            mimetype="application/json",
        )
     
     
    @app_b.get("/work")
    def service_b_work():
        span = current_span_ids()
        return json_response(
            {
                "service": "service-b",
                "received_traceparent": request.headers.get("traceparent", ""),
                "trace_id": span["trace_id"],
                "span_id": span["span_id"],
                "parent_span_id": span["parent_span_id"],
            }
        )
     
     
    @app_a.get("/call-b")
    def service_a_call_b():
        service_a = current_span_ids()
        downstream = requests.get("http://127.0.0.1:8082/work", timeout=5).json()
        traceparent_parts = downstream["received_traceparent"].split("-")
        traceparent_parent_span_id = traceparent_parts[2] if len(traceparent_parts) == 4 else ""
        return json_response(
            {
                "same_trace": service_a["trace_id"] == downstream["trace_id"],
                "service_a_trace_id": service_a["trace_id"],
                "service_b_trace_id": downstream["trace_id"],
                "traceparent_header": downstream["received_traceparent"],
                "traceparent_parent_span_id": traceparent_parent_span_id,
                "service_b_parent_span_id": downstream["parent_span_id"],
                "parent_matches_traceparent": downstream["parent_span_id"]
                == traceparent_parent_span_id,
            }
        )
     
     
    def serve(app, port):
        server = make_server("127.0.0.1", port, app)
        server.serve_forever()
     
     
    if __name__ == "__main__":
        threading.Thread(target=serve, args=(app_b, 8082), daemon=True).start()
        threading.Thread(target=serve, args=(app_a, 8081), daemon=True).start()
        print("service-a listening on http://127.0.0.1:8081/call-b", flush=True)
        print("service-b listening on http://127.0.0.1:8082/work", flush=True)
        threading.Event().wait()

    The response exposes only the IDs needed for the propagation check. A real service would send spans to a Collector or backend instead of returning trace fields to callers.

  5. Start both local services in the first terminal.
    $ python propagation_demo.py
    service-a listening on http://127.0.0.1:8081/call-b
    service-b listening on http://127.0.0.1:8082/work

    Leave this terminal open because it also prints the exported spans.

  6. Open a second terminal in the same directory and activate the virtual environment.
    $ . context-propagation-demo/bin/activate
  7. Send one request through service A.
    $ curl --silent --show-error http://127.0.0.1:8081/call-b
    {
      "parent_matches_traceparent": true,
      "same_trace": true,
      "service_a_trace_id": "1d1138b5a46fcabb2825eeaa6fd499d0",
      "service_b_parent_span_id": "43d2da8175555cc8",
      "service_b_trace_id": "1d1138b5a46fcabb2825eeaa6fd499d0",
      "traceparent_header": "00-1d1138b5a46fcabb2825eeaa6fd499d0-43d2da8175555cc8-03",
      "traceparent_parent_span_id": "43d2da8175555cc8"
    }

    same_trace should be true, and parent_matches_traceparent should be true. Those fields show that service B joined service A's trace and used the outgoing client span ID from the traceparent header as its remote parent.

  8. Inspect the first terminal for the service B server span and the outgoing client span.
    {
        "name": "GET /work",
        "context": {
            "trace_id": "0x1d1138b5a46fcabb2825eeaa6fd499d0",
            "span_id": "0x77ced0f817c31591",
            "trace_state": "[]"
        },
        "kind": "SpanKind.SERVER",
        "parent_id": "0x43d2da8175555cc8",
    ##### snipped #####
    }
    {
        "name": "GET",
        "context": {
            "trace_id": "0x1d1138b5a46fcabb2825eeaa6fd499d0",
            "span_id": "0x43d2da8175555cc8",
            "trace_state": "[]"
        },
        "kind": "SpanKind.CLIENT",
        "parent_id": "0x73f1d6fc1cabb37d",
    ##### snipped #####
    }

    The GET /work server span should share the trace ID with the GET client span. Its parent_id should match the client span ID that service A sent as the traceparent parent ID.

  9. Stop the service terminal after the propagation check.
    Ctrl+C
  10. Remove the demo files.
    $ rm -r context-propagation-demo propagation_demo.py