Tuning BatchSpanProcessor for Throughput

The default BatchSpanProcessor settings are chosen to be safe on a laptop. On a service producing thousands of spans a second they fill a 2048-span queue in well under a second, and the SDK starts dropping — silently, because dropping is the correct behaviour and nothing about it is an error. This page covers the arithmetic that tells you whether your settings work, and what to do when they do not. It builds on exporters and the OpenTelemetry Collector, part of the distributed tracing and OpenTelemetry in Python section.

The arithmetic that decides whether the queue ever empties A bounded queue with production on one side and drain on the other, and the arithmetic for each. Production is requests per second multiplied by spans per request: a service handling four hundred requests a second, each producing twelve spans, produces four thousand eight hundred spans a second. Drain is the maximum export batch size divided by the schedule delay, adjusted for how fast the exporter actually completes a round trip: five hundred and twelve spans every five seconds is about one hundred spans a second at the scheduled cadence, though the processor also exports immediately whenever a full batch accumulates, which raises the effective ceiling to whatever the exporter can complete. When production exceeds drain the queue fills at the difference, so a two thousand and forty-eight span queue is exhausted in well under a second and every span after that is dropped. The conclusion drawn is that queue size only buys time against a temporary imbalance; a permanent one is fixed by exporting faster or producing fewer spans. production rate vs drain rate — everything else is detail production 400 req/s × 12 spans/req = 4 800 spans/s spans, not requests max_queue_size = 2048 full in 0.4 s drain 512 spans / 5 s at the cadence ≈ 100 spans/s scheduled plus immediate export on a full batch what the queue size actually buys production > drain, temporarily — the queue absorbs it, and empties again once the blip passes. This is what it is for. production > drain, permanently — the queue fills at the difference and stays full, whatever size you choose. so a steadily rising drop count is never a queue-size problem: export faster, or produce fewer spans
Enlarging the queue solves a burst. It does nothing at all for a service that simply produces spans faster than it can export them.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
export OTEL_BSP_MAX_QUEUE_SIZE=2048
export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
export OTEL_BSP_SCHEDULE_DELAY=5000
export OTEL_BSP_EXPORT_TIMEOUT=10000

Implementation

Step 1 — Measure spans per request, not requests. This is the number that turns a comfortable-looking queue into a full one. Count from a real trace rather than estimating.

from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult

class CountingExporter(SpanExporter):
    """Wrap the real exporter to count spans in a staging run."""

    def __init__(self, inner: SpanExporter):
        self.inner, self.count = inner, 0

    def export(self, spans) -> SpanExportResult:
        self.count += len(spans)
        return self.inner.export(spans)

    def shutdown(self) -> None:
        print(f"exported {self.count} spans")
        self.inner.shutdown()

A typical instrumented request produces a server span, one per outbound HTTP call, one per database statement, plus manual ones — five to twenty is normal. At 400 requests per second and twelve spans each, the service produces 4 800 spans per second, and the default 2 048-span queue holds 0.4 seconds of that.

Step 2 — Check the drain arithmetic. The scheduled drain is max_export_batch_size / schedule_delay. The processor also exports immediately whenever a full batch has accumulated, so the real ceiling is how fast the exporter completes round trips — but if the scheduled rate is far below production, the configuration is relying entirely on the immediate path and has no headroom.

Quantity Formula Example
Production rps × spans_per_request 400 × 12 = 4 800/s
Scheduled drain batch / delay 512 / 5 s ≈ 100/s
Effective drain batch / export_round_trip 512 / 40 ms ≈ 12 800/s
Queue headroom queue / (production − drain) only meaningful when negative

The middle two rows are the useful ones. If the export round trip is fast — a local Collector, single-digit milliseconds — the effective drain is far above production and the queue never fills. If the Collector is remote and each export takes 200 ms, the effective drain is 2 560 spans per second, below production, and the queue fills at 2 240 spans per second: full in under a second, dropping thereafter.

Step 3 — Size the queue for a blip. A few seconds of peak production is the right target.

from opentelemetry.sdk.trace.export import BatchSpanProcessor

SPANS_PER_SECOND = 4_800
processor = BatchSpanProcessor(
    exporter,
    max_queue_size=SPANS_PER_SECOND * 3,       # ~3 s of headroom
    max_export_batch_size=512,
    schedule_delay_millis=2000,                # tighter cadence, lower peak occupancy
    export_timeout_millis=10000,
)

Beyond that, memory is the constraint: a span with a dozen attributes is on the order of a kilobyte, so a 100 000-span queue is roughly 100 MB of live objects that the garbage collector must also walk.

Three queue-occupancy shapes, and what each one means Queue occupancy plotted over time in three scenarios. In the healthy case occupancy stays near the bottom of the range with small ripples as batches accumulate and drain, and no spans are ever dropped. In the blip case the Collector becomes unavailable for a few seconds: occupancy climbs steeply, touches the ceiling briefly, and falls back once the Collector recovers and the backlog drains — a small number of spans are dropped at the peak, and the shape recovers on its own. In the sustained case the export rate is permanently below the production rate: occupancy climbs to the ceiling and stays there, and spans are dropped continuously from that point on. The distinction matters because the first two are fixed by queue size and the third is not — a flat-topped occupancy curve means the exporter cannot keep up, and only faster export or fewer spans changes it. queue occupancy over time healthy near empty, small ripples · 0 dropped a blip ceiling a handful dropped at the peak, then it recovers on its own sustained flat-topped — dropping continuously from here on the flat top is the diagnosis: a larger queue moves the line up and changes nothing else
A flat-topped occupancy curve is the one shape a bigger queue cannot fix. It means the exporter is permanently behind, and only sampling or a faster export path changes that.

Step 4 — Export the drop counter. The SDK counts dropped spans internally; publish it so the gap is attributable.

from opentelemetry import metrics

meter = metrics.get_meter("otel.self")
dropped = meter.create_observable_gauge(
    "otel_span_queue_dropped",
    callbacks=[lambda options: [metrics.Observation(processor._dropped_spans)]],
    description="Spans discarded because the export queue was full",
)

Alert on any sustained non-zero value. A trace missing its middle spans reads as "that service did not make the call", and an on-call engineer will act on that reading unless the drop counter says otherwise.

Step 5 — Sample rather than enlarge, when the imbalance is permanent. If occupancy sits at the ceiling, the fix is fewer spans, not more room for them.

export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1        # whole traces, 10% of them

Head sampling keeps traces whole, which matters far more than keeping a random 10% of spans. The trade-offs are covered in sampling strategies for distributed tracing.

One request is not one span A single instrumented request decomposed into the spans it actually produces. The framework instrumentation contributes one server span. The database instrumentation contributes one span per statement, and this request runs four: a session lookup, the main select, a related-rows select and a commit. The HTTP client instrumentation contributes one span per outbound call, and this request makes three: an auth check, an inventory lookup and a pricing call. The cache client contributes two. The handler's own manual instrumentation adds two more around the logic worth measuring. Twelve spans for one request, which is entirely ordinary, and which means a service handling four hundred requests a second produces four thousand eight hundred spans a second. The point drawn is that any queue sizing done against request rate is wrong by roughly this factor, and the factor is different for every endpoint. GET /orders/{id} — one request, twelve spans framework · 1 database · 4 HTTP client · 3 cache · 2 manual · 2 session lookup · the select · related rows · commit — the database instrumentation alone is four times the framework's contribution 400 requests/s × 12 spans = 4 800 spans/s a queue sized against request rate is wrong by this factor — and the factor differs per endpoint measure it, do not estimate it a counting exporter in staging gives the real number in one run, including the spans added by instrumentation you forgot was enabled
Four of those twelve come from the database driver alone. The multiplier is different per endpoint, which is why it is worth measuring rather than assuming.

Configuration options

Option Env var Default Recommended
max_queue_size OTEL_BSP_MAX_QUEUE_SIZE 2048 ~3 s of peak span production
max_export_batch_size OTEL_BSP_MAX_EXPORT_BATCH_SIZE 512 512, raise only with a fast Collector
schedule_delay_millis OTEL_BSP_SCHEDULE_DELAY 5000 1000–2000 at high volume
export_timeout_millis OTEL_BSP_EXPORT_TIMEOUT 30000 10000
Sampler OTEL_TRACES_SAMPLER parentbased_always_on ratio-based above a few hundred rps
Drop metric internal only exported and alerted on

Verification

Drive a known number of spans through a deliberately slow exporter and confirm the drop count matches the arithmetic.

import time
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult

class SlowExporter(SpanExporter):
    def export(self, spans):
        time.sleep(0.5)                       # a Collector having a bad day
        return SpanExportResult.SUCCESS
    def shutdown(self): pass

processor = BatchSpanProcessor(SlowExporter(), max_queue_size=100, max_export_batch_size=10)
provider = TracerProvider()
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("probe")
for i in range(1000):
    with tracer.start_as_current_span(f"span-{i}"):
        pass

provider.force_flush(timeout_millis=2000)
print("dropped:", processor._dropped_spans)

Expected Output:

dropped: 871

That is the behaviour to internalise: nothing raised, nothing logged at WARNING, the application ran at full speed, and 87% of the spans do not exist. Then repeat with a fast exporter and confirm the count is zero — which is the assertion worth keeping in a staging smoke test.

Common mistakes

The queue was sized against requests

Error signature: a queue set to twice the peak request rate fills within a second. Root cause: each request produces five to twenty spans. Remediation: measure spans per request with a counting exporter, and size against the product.

Drops are invisible

Error signature: traces have gaps that look like missing work, and nothing in the logs mentions telemetry. Root cause: the SDK counts drops internally and reports them nowhere by default. Remediation: export the counter and alert on a sustained non-zero value.

The queue was enlarged instead of sampling

Error signature: memory climbs after raising max_queue_size, and drops continue. Root cause: the export rate is permanently below production, so the queue is a buffer for a deficit that never clears. Remediation: read the occupancy shape — a flat top means sampling, not sizing.

The parameters that interact

The four settings are not independent, and two pairs in particular produce surprising behaviour when tuned separately.

Batch size against export timeout. A larger batch takes longer to serialise and longer to transmit, so raising max_export_batch_size without raising the timeout increases the chance a batch times out — and a timed-out batch is dropped whole, so a larger batch means more spans lost per failure. The pairing to keep in mind is that the timeout must comfortably exceed the observed round trip for a full batch, with headroom for a slow moment.

Schedule delay against queue size. The delay sets how long a span may wait before its batch is sent, so it also sets how many spans accumulate between drains. A five-second delay on a service producing 4 800 spans per second implies up to 24 000 spans arriving between scheduled drains — which is why the immediate-export-on-full-batch path does most of the work at that volume, and why lowering the delay is often more effective than raising the queue.

Queue size against memory. Each queued span holds its attributes, events and links, so a span with twenty attributes and an exception event is several kilobytes rather than a few hundred bytes. A 100 000-span queue is therefore potentially hundreds of megabytes of live objects, all of which the garbage collector walks on every generation-2 pass — which is a second-order cost that shows up as longer GC pauses rather than as anything the tracing metrics report.

Raise this And also check Or you get
max_export_batch_size the export timeout larger losses per timeout
schedule_delay the queue size queue saturation between drains
max_queue_size process memory and GC pauses longer collections, more memory
Nothing the sampling rate a permanent deficit that no setting fixes

A worked starting point

For a service producing roughly 5 000 spans per second per process, with a Collector on the same host answering in single-digit milliseconds:

BatchSpanProcessor(
    OTLPSpanExporter(insecure=True),
    max_queue_size=15_000,          # ~3 s of production
    max_export_batch_size=512,      # small enough to serialise quickly
    schedule_delay_millis=1_000,    # tighter than the default; lowers peak occupancy
    export_timeout_millis=10_000,   # far above the observed round trip
)

The reasoning, in order: the queue covers a three-second blip, which is long enough to ride out a Collector restart and short enough that memory stays modest. The batch stays at the default because a local Collector completes it in milliseconds and a larger batch buys nothing. The delay drops to one second because at this volume the scheduled cadence should be doing real work rather than being bypassed entirely by the full-batch path. And the timeout is generous because its only job is to bound a pathological case.

Then measure. The occupancy shape from the second figure is the check: ripples near the bottom mean the settings are comfortable, a flat top means the arithmetic does not work and sampling is the answer.

Under multiple processes

Each worker in a prefork deployment has its own provider, queue and exporter thread, which means the per-process numbers above multiply by the worker count for memory and for connections to the Collector. Four workers with a 15 000-span queue each is 60 000 spans of headroom and four concurrent exporters, which is usually fine and is worth knowing before choosing a queue size from a per-service span rate rather than a per-process one.

It also means the provider must be built after the fork, for the reason set out in the Flask walkthrough: a BatchSpanProcessor created before forking has an exporter thread that does not survive, leaving each worker with a queue nothing drains.

Frequently Asked Questions

What actually happens when the span queue is full?

The new span is dropped and an internal counter is incremented. It is not the oldest span that goes, and nothing blocks — the SDK deliberately chooses to lose telemetry rather than to apply backpressure to the application. That choice is right, and it means a full queue is a silent data-quality problem rather than a visible failure.

Should I just make max_queue_size very large?

Only up to a few seconds of peak production. The queue exists to absorb a blip in the exporter or the Collector; sizing it for a ten-minute outage means holding hundreds of thousands of span objects in memory, and the failure mode becomes an OOM kill of the application instead of a gap in the traces. If the queue is filling steadily rather than in bursts, the export rate is the problem and sampling is the fix.

How do batch size and schedule delay interact?

They set the maximum drain rate together: max_export_batch_size divided by schedule_delay gives spans per second, assuming the exporter keeps up. A batch of 512 every 5 seconds drains about 100 spans per second, which is far below what a busy service produces — the SDK also exports as soon as a full batch is available, so the delay is an upper bound on latency rather than a fixed period, but the ratio is still the first thing to check.

Does one request produce one span?

Almost never. An instrumented request typically produces a server span, one span per outbound HTTP call, one per database query, and whatever manual spans the handler creates — commonly five to twenty. Sizing the queue against request rate rather than span rate under-provisions it by that factor, which is the most common reason a queue that looks generous fills in seconds.