Tracing asyncio gather and TaskGroups

Concurrent fan-out with asyncio.gather or a TaskGroup is one of the places where tracing earns its keep: the parent's duration is the slowest child's, and only per-branch spans say which child that was. The basic nesting works automatically, because tasks copy the context that was current when they were created. What needs deliberate handling is everything around failure — the branch that raised, the siblings a TaskGroup cancels in response, and the exception group that arrives at the parent. This page covers span structure for fan-out and recording partial failure and cancellation honestly. It is a task article under async tracing patterns, part of the distributed tracing and OpenTelemetry in Python section.

Failure, cancellation and the parent A parent span wraps a TaskGroup that starts three branches: inventory, pricing and shipping. Pricing fails after two hundred milliseconds with a connection error. The TaskGroup immediately cancels the other two branches, which were still waiting on I/O. Inventory's span ends at that moment with a cancellation, having done part of its work; shipping's span ends likewise. The parent receives an ExceptionGroup containing only the pricing error, because cancelled tasks do not contribute to the group. Correctly recorded, the pricing span has error status with its exception recorded; the inventory and shipping spans are marked cancelled with an attribute, not an error; and the parent records one failure and two cancellations. Recorded naively, all three children end without status, or all three are marked as errors, and the trace does not say which branch caused the failure. The note records that the difference matters most when reading error rates per operation. one branch fails; the TaskGroup cancels the rest build quote — 1 failed, 2 cancelled inventory — cancelled pricing — ERROR, ConnectionError shipping — cancelled failure → siblings cancelled here recorded correctly error only on the branch that failed cancelled marked as cancelled error rate per operation stays true recorded naively all three unset, or all three ERROR the cause is not identifiable inventory's error rate inflated by pricing
A cancelled sibling did nothing wrong. Marking it as an error inflates its error rate with another operation's failures.

Prerequisites

pip install "opentelemetry-api>=1.27.0,<2.0.0" \
            "opentelemetry-sdk>=1.27.0,<2.0.0"

TaskGroup requires Python 3.11 or later.

Implementation

Step 1 — Wrap the fan-out in a parent span. A span around the concurrent section gives the branches a common parent and measures the fan-out as a whole. Because tasks copy the context at creation, starting the parent before creating the tasks is all that is needed for the children to nest under it.

import asyncio
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("quotes")

async def build_quote(order):
    with tracer.start_as_current_span("build quote") as parent:
        async with asyncio.TaskGroup() as tg:
            inv = tg.create_task(traced("inventory", fetch_inventory, order))
            pri = tg.create_task(traced("pricing", fetch_pricing, order))
            shp = tg.create_task(traced("shipping", fetch_shipping, order))
        return combine(inv.result(), pri.result(), shp.result())

Step 2 — Give each branch its own span. Each concurrent unit starts its own span, so its duration and outcome are visible separately. The parent's duration is roughly the slowest child's; only the children's spans say which one that was.

Step 3 — Record failures on the branch that failed. When a branch raises, its span records the exception and sets error status before the exception propagates. That puts the failure on the operation that caused it rather than only on the parent, where it arrives wrapped in an exception group.

Step 4 — Mark cancellation distinctly. When one branch fails, a TaskGroup cancels the others, and each receives CancelledError at its current await. A span that simply ends as that exception passes through looks like either a success or — if the instrumentation records every exception as an error — a failure. Catching CancelledError, marking the span, and re-raising records what actually happened.

async def traced(name: str, fn, *args):
    with tracer.start_as_current_span(name, record_exception=False,
                                      set_status_on_exception=False) as span:
        try:
            return await fn(*args)
        except asyncio.CancelledError:
            span.set_attribute("async.cancelled", True)     # 1. stopped, not failed
            span.set_status(Status(StatusCode.UNSET))
            raise                                            # cancellation must propagate
        except Exception as exc:
            span.record_exception(exc)                       # 2. the real failure
            span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
            raise

Step 5 — Summarise the outcome on the parent. The parent receives an ExceptionGroup containing the failures, not the cancellations. Recording counts on the parent — succeeded, failed, cancelled — gives a one-line summary of the fan-out visible without opening the children, and the parent's own status reflects that the operation as a whole failed.

        # inside build_quote, around the TaskGroup
        try:
            async with asyncio.TaskGroup() as tg:
                tasks = {n: tg.create_task(traced(n, f, order)) for n, f in BRANCHES.items()}
        except* Exception as eg:
            parent.set_attribute("fanout.failed", len(eg.exceptions))
            parent.set_attribute("fanout.cancelled",
                                 sum(1 for t in tasks.values() if t.cancelled()))
            parent.set_status(Status(StatusCode.ERROR, "fan-out failed"))
            raise
        parent.set_attribute("fanout.succeeded", len(tasks))

Expected Output: the trace as exported, with each span's status telling the true story.

build quote   1.02s  ERROR   fanout.failed=1 fanout.cancelled=2
  inventory   0.21s  UNSET   async.cancelled=true
  pricing     0.20s  ERROR   exception: ConnectionError pricing unreachable
  shipping    0.21s  UNSET   async.cancelled=true
gather and TaskGroup fail differently The same three branches are run two ways when pricing fails. With asyncio.gather and return_exceptions set to true, the failure is returned as a value and the other two branches run to completion; their spans end normally with their full durations, and the parent span receives a list containing two results and one exception. With a TaskGroup, the failure triggers cancellation of the other two branches at their next await, so their spans end early, and the parent receives an ExceptionGroup. With plain gather and return_exceptions false, the first exception propagates to the parent immediately but the other tasks are not cancelled and continue running in the background, so their spans end after the parent has already finished — children outliving their parent in the trace. The note records that the third case is the one that produces confusing traces and orphaned work. pricing fails — what happens to the siblings gather(return_exceptions=True) siblings finish · parent gets results + exception TaskGroup siblings cancelled early parent gets ExceptionGroup gather() default parent ends at failure siblings keep running, unowned child spans end after their parent — and their work is nobody's prefer TaskGroup for structured cancellation, or gather with return_exceptions for independent branches plain gather leaves work running after the parent has reported failure
The concurrency primitive decides what happens to siblings on failure, and the trace faithfully records whichever it was. Plain gather is the one that produces orphaned work.

Reading a fan-out trace

Once each branch has its own span with an honest status, a fan-out trace answers three questions quickly, and it is worth knowing where to look for each.

Which branch set the duration? The parent's duration is the slowest child's plus a little overhead. The child whose end aligns with the parent's end is the one that determined latency, and it is the only one whose optimisation would make the parent faster. Improving any other branch changes nothing the caller can see, which is a useful thing to know before spending effort on it.

Did the branches actually run concurrently? Children whose start times are staggered, each beginning as the previous one ends, are running sequentially despite the fan-out — usually because something inside them is synchronous and blocking the loop, or because they share a resource with a concurrency limit of one. The trace shows it immediately as a staircase where a column was expected. The causes and their diagnosis are in diagnosing blocked event loops in production.

What was lost when it failed? Cancelled branches carry their cancellation marker and their partial duration. A branch cancelled after doing most of its work — a write that completed but whose confirmation was never processed — is a correctness question as well as a performance one, and the trace is the evidence of what state was left behind.

These readings depend entirely on each branch having its own span. A fan-out traced only at the parent level shows a duration and an outcome and nothing about why.

Large fan-outs

The patterns above assume a handful of branches. A fan-out over hundreds or thousands of items — processing a batch concurrently, querying many shards — needs a different span structure, because a span per item produces traces no viewer can open and export volumes that dominate the service's telemetry.

Bound concurrency and span the batches. Large fan-outs usually run through a semaphore or a bounded pool anyway, to avoid overwhelming downstream services. A span per concurrent slot or per chunk of items, rather than per item, keeps the span count proportional to the concurrency rather than to the input size.

Record counts and outliers on the parent. How many items succeeded, failed and were retried, the slowest item's duration, and the identifiers of the items that failed — capped to a small number — carry most of what a per-item span would have shown, as attributes on one span.

Keep per-item spans for failures only. Starting a span only when an item fails, or recording a span event on the parent for each failure, keeps the detail where it is needed. Successful items rarely need to be seen individually.

Consider links instead of children. When the fan-out processes items that belong to different upstream requests — a consumer processing a batch of messages, for instance — each item relates to a different trace, and links from the batch span to each originating context describe that far better than children would. Adding span links for batch work covers the mechanism.

The underlying principle matches the one for batch jobs in telemetry from serverless and batch Python: spans describe units of work worth looking at individually, and counts describe the rest.

How wide a fan-out can a trace carry A bar chart of span counts in one request trace as the fan-out grows. Ten concurrent child tasks produce eleven spans, which any backend displays comfortably. One hundred produce a hundred and one, still readable though the waterfall is long. One thousand produce over a thousand spans, which approaches the per-trace limits many backends enforce and makes the waterfall unusable. Ten thousand exceed those limits, so spans are dropped or the trace is truncated. The note says that past a few hundred children, one span for the whole batch with counts and failure totals as attributes, plus links or events for the failures, tells the story better than a span per task. spans in one trace, by fan-out width 10 tasks 11 spans · easy to read 100 tasks 101 spans · long but usable 1 000 tasks 1 001 spans · near backend limits 10 000 tasks 10 001 spans · truncated or dropped past a few hundred children, one batch span with counts tells the story better record failures as events or links rather than a span per task
A span per task is right for a handful of branches and wrong for thousands. The waterfall stops being readable long before the backend limit.

Configuration options

Concern Recommendation Why
Parent span around the whole fan-out common parent, total duration
Branch spans one per concurrent unit which branch set the latency
Failed branch exception recorded, error status failure on the operation that caused it
Cancelled branch attribute, status unset not counted as an error
Parent outcome counts of succeeded/failed/cancelled one-line summary
Primitive TaskGroup or gather(return_exceptions=True) no orphaned siblings
Context inherited at task creation start the parent before creating tasks

Verification

Run a fan-out with one failing branch in a test and assert on each span's status.

def test_cancelled_siblings_are_not_errors(exporter):
    with pytest.raises(ExceptionGroup):
        asyncio.run(build_quote(order_with_failing_pricing()))
    by_name = {s.name: s for s in exporter.get_finished_spans()}
    assert by_name["pricing"].status.status_code is StatusCode.ERROR
    for sibling in ("inventory", "shipping"):
        assert by_name[sibling].attributes.get("async.cancelled") is True
        assert by_name[sibling].status.status_code is StatusCode.UNSET

Expected Output:

.
1 passed

Common mistakes

No span per branch. Error signature: a slow fan-out with no indication of which branch was slow. Root cause: only the parent is instrumented. Remediation: a span inside each concurrent unit.

Cancellation recorded as an error. Error signature: inflated error rates on operations that were merely cancelled. Root cause: instrumentation that records every exception as a failure. Remediation: catch CancelledError, mark it, re-raise.

Swallowing CancelledError. Error signature: a TaskGroup that hangs or tasks that ignore shutdown. Root cause: catching cancellation without re-raising. Remediation: always re-raise it.

Plain gather with independent failures. Error signature: child spans ending after their parent; background work continuing after a request failed. Root cause: siblings not cancelled on the first exception. Remediation: TaskGroup, or return_exceptions=True.

Relying on the parent's exception group for diagnosis. Error signature: an investigation that has to parse exception text to learn which branch failed. Root cause: failures recorded only where they arrive. Remediation: record each failure on its own branch span.

Parent started after task creation. Error signature: branch spans parented to the request instead of the fan-out. Root cause: tasks copied the context before the parent span existed. Remediation: start the parent first.

Frequently Asked Questions

Do tasks created by gather inherit the current span?

Yes. asyncio copies the context when a task is created, so a span that is current when gather or a TaskGroup creates its tasks becomes the parent of spans those tasks start. Nothing extra is needed for the nesting to be correct.

What happens to span context in a TaskGroup when one task fails?

The TaskGroup cancels the remaining tasks and raises an ExceptionGroup. Each cancelled task receives CancelledError at its current await point, and any span it had open ends as that exception propagates — without a status saying it was cancelled unless the code sets one.

Should cancelled spans be marked as errors?

Not as ordinary errors. A cancelled sibling did nothing wrong; it was stopped because another branch failed. Recording a clear attribute such as a cancellation marker, and reserving error status for the branch that actually failed, keeps error rates meaningful.

How do I see which branch made the fan-out slow?

Give each branch its own span. The parent's duration equals the slowest child plus overhead, and the trace timeline shows exactly which child that was.