Skip to content

BatchSpanProcessor.Shutdown: exporter Shutdown called with cancelled context, error silently dropped #8631

Description

@tsushanth

Description

In sdk/trace/batch_span_processor.go, batchSpanProcessor.Shutdown spawns a goroutine to drain the queue and shut down the exporter. When the caller's ctx expires before the goroutine finishes, the select exits via <-ctx.Done() — but the goroutine continues running and eventually calls bsp.e.Shutdown(ctx) with that already-cancelled context.

go func() {
    close(bsp.stopCh)
    bsp.stopWait.Wait()
    if bsp.e != nil {
        exportErr = bsp.e.Shutdown(ctx)  // ctx is already cancelled
    }
    close(wait)
}()
select {
case <-wait:
    err = exportErr      // only path that reads exportErr
case <-ctx.Done():
    err = ctx.Err()      // goroutine keeps running; exportErr is never read
}

Sequence of events

  1. Caller passes a ctx with a short deadline to bsp.Shutdown(ctx).
  2. Draining the queue takes longer than the deadline; ctx becomes cancelled.
  3. The select exits on <-ctx.Done(), returning ctx.Err() to the caller.
  4. The goroutine is still running. Eventually it calls bsp.e.Shutdown(ctx) with the cancelled context.
  5. Most OTLP/gRPC exporters detect the cancelled context and return immediately without flushing.
  6. exportErr is written by the goroutine, but the <-wait branch of the select is never executed, so the error is silently discarded.
  7. Because stopOnce has already fired, no subsequent Shutdown call is possible, leaving the exporter permanently un-closed and any remaining spans lost with no signal to the caller.

Expected behavior

When the caller's context expires, the background goroutine should still shut down the exporter using a fresh, independent context so the exporter has a real opportunity to flush. Any error from the exporter shutdown should be surfaced (e.g. via otel.Handle).

Suggested fix

Replace the cancelled context with a fresh timeout in the goroutine:

go func() {
    close(bsp.stopCh)
    bsp.stopWait.Wait()
    if bsp.e != nil {
        shutCtx, shutCancel := context.WithTimeout(context.Background(), defaultShutdownGrace)
        defer shutCancel()
        if err := bsp.e.Shutdown(shutCtx); err != nil {
            otel.Handle(err)
        }
    }
    close(wait)
}()

where defaultShutdownGrace is a small constant (e.g. 5 s) that gives the exporter a final chance to flush independent of the caller's deadline.

Environment

  • File: sdk/trace/batch_span_processor.go (~line 162)
  • Affects any OTLP/gRPC exporter that returns early on a cancelled context (which is the correct gRPC behaviour)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:tracePart of OpenTelemetry tracingblocked:specificationWaiting on clarification of the OpenTelemetry specification before progress can be madepkg:SDKRelated to an SDK packagequestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions