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.
$ python3 -m venv context-propagation-demo
$ . context-propagation-demo/bin/activate
$ 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.
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.
$ 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.
$ . context-propagation-demo/bin/activate
$ 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.
{
"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.
Ctrl+C
$ rm -r context-propagation-demo propagation_demo.py