Skip to content

perf(sdk,otlp): add SpanBatch for borrowed span export, eliminate exporter clones#3379

Open
bryantbiggs wants to merge 4 commits intoopen-telemetry:mainfrom
bryantbiggs:perf/span-exporter-borrowed-data
Open

perf(sdk,otlp): add SpanBatch for borrowed span export, eliminate exporter clones#3379
bryantbiggs wants to merge 4 commits intoopen-telemetry:mainfrom
bryantbiggs:perf/span-exporter-borrowed-data

Conversation

@bryantbiggs
Copy link
Copy Markdown
Contributor

@bryantbiggs bryantbiggs commented Feb 20, 2026

Summary

Changes SpanExporter::export from Vec<SpanData> to SpanBatch<'_> — a thin borrowed wrapper around &[SpanData]. This matches the LogBatch<'_> pattern already used in the logs pipeline. Additionally eliminates unnecessary SpanData deep clones in the tonic and HTTP trace exporters.

This is a breaking change to the SpanExporter trait. Implementors need to update their export signature and use batch.iter() or batch.as_slice() to access spans.

Supersedes #3066. Related to #3368.

Motivation

The span export path currently requires ownership transfer of a Vec<SpanData>, which forces unnecessary allocations and clones:

  1. batch.split_off(0) in BatchSpanProcessor — allocates a new Vec every export cycle just to hand ownership to the exporter, even though the exporter only needs to read the spans
  2. vec![span] in SimpleSpanProcessor — heap-allocates a Vec for a single span
  3. span_data.clone().into() in group_spans_by_resource_and_scope — deep-clones every SpanData during proto conversion because the function consumed the Vec
  4. batch.iter().cloned().collect() in tonic trace exporter — deep-clones every SpanData into an Arc<Vec<_>> for the retry closure, even though retries rarely happen
  5. spans.iter().cloned().collect() in HTTP trace exporter — deep-clones spans into an intermediate Vec before proto conversion, serving no purpose

The logs pipeline already solved this with LogBatch<'_>. There's even a TODO in the codebase (span_processor.rs):

"TODO: Compared to Logs, this requires new allocation for vec for every export. See if this can be optimized by not requiring ownership in the exporter."

Allocation impact (512-span batch via tonic)

Metric Before After Reduction
Vec allocations per export cycle 1 (split_off) 0 (buffer reuse) 100%
SpanData deep clones (proto conversion) 512 0 100%
SpanData deep clones (tonic retry Arc) 512 0 100%
SpanData deep clones (HTTP intermediate Vec) 512 0 100%
Estimated heap allocs per batch ~28,000–38,000 ~10,000–13,000 ~40–50%

Ecosystem alignment

Project Export interface Pattern
Go OTel SDK []ReadOnlySpan (borrowed slice) Borrowed
Java OTel SDK Collection<SpanData> (immutable) Immutable
Rust OTel (logs) LogBatch<'_> (borrowed) Borrowed
Rust OTel (traces, before) Vec<SpanData> (owned) Consuming
Rust OTel (traces, after) SpanBatch<'_> (borrowed) Borrowed

What changed

opentelemetry-sdk (core change)

  • New SpanBatch<'a> type wrapping &'a [SpanData] with new(), iter(), and as_slice() methods
  • SpanExporter::export signature: Vec<SpanData>SpanBatch<'_>
  • BatchSpanProcessor: replaced split_off(0) with SpanBatch::new(&batch) + batch.clear()
  • SimpleSpanProcessor: replaced vec![span] with stack array + SpanBatch::new(&spans)
  • Updated InMemorySpanExporter, test exporters, and benchmarks

opentelemetry-proto

  • Added From<&SpanData> for Span and From<&Link> for span::Link (borrowing conversions)
  • group_spans_by_resource_and_scope now takes &[SpanData] instead of Vec<SpanData>

opentelemetry-otlp

  • Tonic trace exporter: replaced Arc<Vec<SpanData>> (512 deep clones) with Arc<SpanBatch<'_>> (~free), matching the existing Arc<LogBatch> pattern in the tonic logs exporter
  • HTTP trace exporter: removed unnecessary intermediate Vec<SpanData> clone, using spans.as_slice() directly

opentelemetry-stdout, opentelemetry-zipkin

  • Updated trace exporters to accept SpanBatch<'_>

Migration guide

// Before
impl SpanExporter for MyExporter {
    async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
        for span in &batch { /* ... */ }
        Ok(())
    }
}

// After
use opentelemetry_sdk::trace::SpanBatch;

impl SpanExporter for MyExporter {
    async fn export(&self, batch: SpanBatch<'_>) -> OTelSdkResult {
        for span in batch.iter() { /* ... */ }
        Ok(())
    }
}

Verification

cargo fmt --all -- --check                    # clean
cargo clippy --workspace --all-features       # zero warnings
RUSTFLAGS="-D warnings" cargo check --workspace --tests  # compiles with deny(warnings)
cargo test --workspace                        # all pass, zero failures

@bryantbiggs bryantbiggs requested a review from a team as a code owner February 20, 2026 19:46
@bryantbiggs bryantbiggs changed the title feat(sdk): add SpanBatch for borrowed span export perf(sdk,otlp): add SpanBatch for borrowed span export, eliminate exporter clones Feb 20, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Feb 20, 2026

Codecov Report

❌ Patch coverage is 69.44444% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.2%. Comparing base (9650783) to head (817cf54).

Files with missing lines Patch % Lines
opentelemetry-proto/src/transform/trace.rs 63.6% 20 Missing ⚠️
opentelemetry-stdout/src/trace/exporter.rs 0.0% 5 Missing ⚠️
opentelemetry-zipkin/src/exporter/mod.rs 0.0% 4 Missing ⚠️
opentelemetry-otlp/src/exporter/tonic/trace.rs 0.0% 2 Missing ⚠️
opentelemetry-otlp/src/exporter/http/trace.rs 0.0% 1 Missing ⚠️
opentelemetry-otlp/src/span.rs 0.0% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            main   #3379     +/-   ##
=======================================
- Coverage   83.2%   83.2%   -0.1%     
=======================================
  Files        128     128             
  Lines      25045   25112     +67     
=======================================
+ Hits       20858   20905     +47     
- Misses      4187    4207     +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bryantbiggs bryantbiggs force-pushed the perf/span-exporter-borrowed-data branch from eb202cb to d7b14e1 Compare March 6, 2026 01:56
@bryantbiggs bryantbiggs force-pushed the perf/span-exporter-borrowed-data branch from d7b14e1 to cb3e7ec Compare March 14, 2026 17:56
Change SpanExporter::export to take SpanBatch<'_> instead of
Vec<SpanData>, matching the LogBatch pattern already used in the
logs pipeline.

The key win is in BatchSpanProcessor: instead of calling
batch.split_off(0) on every export (which allocates a new Vec and
moves all elements), the processor now wraps the existing buffer
in a SpanBatch reference and clears it after export returns. This
eliminates one Vec allocation per export cycle.

Changes across 6 crates:
- opentelemetry-sdk: new SpanBatch<'a> type, updated processors
- opentelemetry-proto: added From<&SpanData> and From<&Link>,
  group_spans_by_resource_and_scope now takes &[SpanData]
- opentelemetry-otlp: updated tonic and http trace exporters
- opentelemetry-stdout: updated trace exporter
- opentelemetry-zipkin: updated trace exporter
- benchmarks: updated no-op exporters

Breaking change: SpanExporter::export signature changed.
Implementors need to update their export method to accept
SpanBatch<'_> and use batch.iter() to access spans.
The test module imported SpanData but only used SpanBatch and
SpanExporter. CI caught this via #[deny(warnings)].
…xporters

The tonic trace exporter deep-cloned every SpanData into a Vec on each
export call, even though SpanBatch already provides borrowed access to
the underlying slice. Replace Arc<Vec<SpanData>> with Arc<SpanBatch> to
wrap the borrowed data directly (~free), matching the existing pattern
used by the tonic logs exporter with Arc<LogBatch>.

The HTTP trace exporter had the same unnecessary clone in
build_trace_export_body — removed by using SpanBatch::as_slice()
directly.

Adds SpanBatch::as_slice() for callers needing &[SpanData].
The batchspanprocessor_sync_ignores_max_concurrent_exports test was
added on main after this branch diverged and used the old Vec<SpanData>
signature. Updated to use SpanBatch<'_> to match the new trait.
@bryantbiggs bryantbiggs force-pushed the perf/span-exporter-borrowed-data branch from cb3e7ec to 817cf54 Compare March 21, 2026 01:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant