OpenTelemetry Python: Trace a Checkout Flow End to End

Quick answer

Yes. OpenTelemetry Python adds distributed tracing to a Python service with a few lines of setup: create a tracer provider, attach an exporter, and wrap each step of a request in a span. Each span records a name, timing, and attributes, and spans from one request share a trace id. Start with the console exporter, then switch to a real backend when the traces look right.

A customer writes in: checkout took thirty seconds, then failed, and nobody can say where the time went. The order service says the payment call was slow. The payment service says it never saw the request. The logs from both sides exist, but they disagree about what happened first. That is the gap tracing fills. A trace follows one request across every service it touches, giving you one timeline with a name, a duration, and a few facts per step. OpenTelemetry is the shared vocabulary for that timeline, and the Python SDK is the part you wire into your own code.

What I would check before using it

My first check is whether a request crosses a boundary. A single small script gains almost nothing, because a log line already tells you what happened. The payoff starts when a request crosses an HTTP call, a queue, or a database, and you need to know which side was slow.

My second check is who will read the traces. Spans in a terminal are for learning, not operations. In production you need a backend that stores and searches traces, which means running or paying for one. If nobody opens that dashboard during an incident, the instrumentation is decoration.

My third check is how the code is structured. Manual spans work well when each step is a clear function, like the checkout below. If the work is spread across callbacks or threads, keeping the context attached takes real care. A framework with built-in instrumentation, such as FastAPI, removes most of that friction, so look for an integration first.

My fourth check is cost. Every span is data, and a busy service can produce millions a day. Sampling, keeping one trace in ten, keeps the bill sane, but the one failing request you care about may be the one dropped. Make that trade deliberately.

My verdict, as judgement: adopt it when a request crosses at least one service boundary and someone will read the traces during an incident. Start with the console exporter on one flow, and switch only when the spans answer a real question. Skip it for a single-process app. The recommendation flips the moment a second service appears, or debugging by log timestamps starts eating afternoons.

One checkout, traced end to end

The example is a small checkout: validate the order, charge the payment, confirm the order. Three sample orders run through it, each with an id, a customer, a total, and an item count. Each step runs inside a span, and the whole checkout runs inside a parent span, so all four spans for one order share a trace id.

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor

resource = Resource(attributes={"service.name": "checkout-service"})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
tracer = trace.get_tracer("checkout.tracer")

ORDERS = [
    {"order_id": "ORD-2101", "customer": "Ayesha Khan", "total": 12400, "items": 2},
    {"order_id": "ORD-2102", "customer": "Bilal Ahmed", "total": 7800, "items": 1},
    {"order_id": "ORD-2103", "customer": "Sana Malik", "total": 21950, "items": 4},
]


def validate_order(order):
    with tracer.start_as_current_span("validate_order") as span:
        span.set_attribute("order.id", order["order_id"])
        span.set_attribute("order.items", order["items"])
        if order["total"] <= 0:
            raise ValueError("order total must be positive")
        return True


def charge_payment(order):
    with tracer.start_as_current_span("charge_payment") as span:
        span.set_attribute("order.id", order["order_id"])
        span.set_attribute("payment.amount_pkr", order["total"])
        return f"PAY-{order['order_id'][-4:]}"


def confirm_order(order, payment_ref):
    with tracer.start_as_current_span("confirm_order") as span:
        span.set_attribute("order.id", order["order_id"])
        span.set_attribute("payment.ref", payment_ref)
        return f"Confirmed {order['order_id']} for {order['customer']}"


def checkout(order):
    with tracer.start_as_current_span("checkout") as span:
        span.set_attribute("order.id", order["order_id"])
        validate_order(order)
        payment_ref = charge_payment(order)
        result = confirm_order(order, payment_ref)
        print(result)


for order in ORDERS:
    checkout(order)

print("All checkouts done.")

The first run went wrong in the quietest possible way. I had written the provider and the tracer, but not added the span processor, so the spans were created and dropped. The program printed the three confirmation lines, exited 0, and printed no spans at all:

Confirmed ORD-2101 for Ayesha Khan
Confirmed ORD-2102 for Bilal Ahmed
Confirmed ORD-2103 for Sana Malik
All checkouts done.

Nothing failed, nothing warned, and that is why this mistake is so common. The fix is one line, added before the tracer is fetched: provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())). With it in place, the same run printed 12 spans, four per order. Here is one span from the real output (ids and timestamps differ on every run):

{
    "name": "validate_order",
    "context": {
        "trace_id": "0x8ddfe446f4041fce1b4f38234176697c",
        "span_id": "0x502253268eda2d75",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x46e7760eea1ddbef",
    "start_time": "2026-11-14T04:06:39.929369Z",
    "end_time": "2026-11-14T04:06:39.929401Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "order.id": "ORD-2101",
        "order.items": 2
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "service.name": "checkout-service"
        },
        "schema_url": ""
    }
}
Confirmed ORD-2101 for Ayesha Khan
Confirmed ORD-2102 for Bilal Ahmed
Confirmed ORD-2103 for Sana Malik
All checkouts done.
Terminal output showing the OpenTelemetry validate_order span for order ORD-2101, with its trace id, parent id, and attributes.

The useful detail is the parent id. The validate_order span points at the checkout span as its parent, and the checkout span has a null parent id, marking it as the root. That parent link is the whole mechanism: it turns four separate records into one timeline. Spans also print in the order they finish, so children appear before their parent. The same habit pays off once the flow grows past one process, whether services run in Docker containers, talk through Kafka events, or are orchestrated by Kubernetes.

Common mistakes and how to fix them

First, creating spans but never adding a span processor. The symptom is silence: the program runs fine, exits 0, and no spans appear. The fix is to add the processor before creating spans, and run once with the console exporter.

Second, fetching the tracer before setting the provider, or setting it twice. The symptom is a warning about overriding the global provider, or spans landing somewhere unconfigured. The fix is to set the provider once, at the top of your entry point, then fetch the tracer.

Third, high-cardinality values in span attributes, like a full email or a URL carrying a session token. The symptom is a backend that grows fast, costs more, and may store data you should never have logged. The fix is to keep attributes small, an order id, a status, a count.

Fourth, forgetting that context does not cross threads on its own. The symptom is child spans appearing as separate root traces instead of children of the request. The fix is to pass the context into the thread, or use the framework integration that does it for you.

Frequently Asked Questions

What is distributed tracing? It follows one request across every service it touches, recording each step as a span. Spans from one request share a trace id, so you read one timeline, not scattered log lines.

Do I need Jaeger or another backend to start? No. The console exporter prints spans to the terminal and needs nothing else. Move to a backend only when you want to store and share traces.

Is OpenTelemetry only for microservices? No, but that is where it pays best. A single service still benefits when a request has several slow steps. If the whole request is one database query, plain logging is enough.

How much overhead does tracing add? The spans here cost microseconds around steps that take far longer. The real cost is data volume, so plan sampling and retention first.

What to remember

  • A trace is one request, made of spans sharing a trace id.
  • Set the provider once, add a span processor, then fetch the tracer.
  • If spans do not print to the console, no backend will save you.
  • Keep span attributes small, and never put secrets in them.
  • Tracing pays when a request crosses a boundary.

Where this leaves you

You now have a working trace for a checkout flow, built from a provider, a processor, an exporter, and four spans per order, and you have seen the failure that catches most beginners: spans created, never exported, silence mistaken for success. The next step is small on purpose. Take one real flow in your own service, the one people ask about when it is slow, add spans to its steps, and run it with the console exporter. If the spans answer a question your logs could not, you have found where tracing belongs. If not, you spent an afternoon, not a quarter, finding out.

Further reading

Waqas Shahid is a software engineer from Karachi with more than 12 years of experience, including 6+ years in engineering leadership. He writes The Practical Developer.

Comments

Popular posts from this blog

Swift Explained: Apple's Language for iPhone, Mac, and More

Zig Explained: A Modern Systems Language Without the Magic

Rust Explained: Memory Safety Without a Garbage Collector