Skip to content

Add FilteringSpanExporter with composable SpanFilter and TraceFilter interfaces#2745

Open
udaysagar2177 wants to merge 2 commits intoopen-telemetry:mainfrom
udaysagar2177:add-filtering-span-exporter
Open

Add FilteringSpanExporter with composable SpanFilter and TraceFilter interfaces#2745
udaysagar2177 wants to merge 2 commits intoopen-telemetry:mainfrom
udaysagar2177:add-filtering-span-exporter

Conversation

@udaysagar2177
Copy link
Copy Markdown

Summary

Adds a trace-level filtering SpanExporter to the processors module. FilteringSpanExporter wraps a delegate exporter and only forwards spans belonging to traces that match at least one configured filter. Filtering is trace-aware: if any filter matches, all spans sharing that trace ID are exported together.

Two filter interfaces enable composable, user-extensible filtering:

  • SpanFilter — per-span evaluation (e.g., error status, slow duration)
  • TraceFilter — batch-level evaluation over all spans sharing a trace ID (e.g., overall trace wall-clock duration)

Built-in implementations:

Class Type Behavior
ErrorSpanFilter SpanFilter Keeps traces containing any span with StatusCode.ERROR
DurationSpanFilter SpanFilter Keeps traces containing any span exceeding a duration threshold
TraceDurationFilter TraceFilter Keeps traces whose wall-clock duration (max end - min start) exceeds a threshold

An optional Meter parameter emits a filtering.span.exporter.dropped counter with a reason attribute for observability into dropped spans.

Motivation

Services with heavy sampling often want to prioritize exporting traces that contain errors or exhibit unusual latency, without increasing overall sampling rate. This exporter wrapper provides that capability as a composable, upstream component that any OTel Java user can plug into their SDK configuration.

The existing InterceptableSpanExporter in this module operates per-span via the Interceptor interface, but has no trace-level grouping. FilteringSpanExporter fills this gap by grouping spans by trace ID and making keep/drop decisions at the trace level.

Usage

SpanExporter filtered = new FilteringSpanExporter(
    delegate,
    Arrays.asList(new ErrorSpanFilter(), new DurationSpanFilter(2000)),
    Collections.singletonList(new TraceDurationFilter(10000)));

// Custom filters via lambda
SpanFilter nameFilter = span -> span.getName().contains("important");

// With metrics
SpanExporter withMetrics = new FilteringSpanExporter(
    delegate, spanFilters, traceFilters, meter);

Changes

File Description
SpanFilter.java Interface for per-span filtering
TraceFilter.java Interface for batch-level trace filtering
ErrorSpanFilter.java Built-in: keeps traces with error spans
DurationSpanFilter.java Built-in: keeps traces with slow spans
TraceDurationFilter.java Built-in: keeps traces with long wall-clock duration
FilteringSpanExporter.java Core exporter wrapper with single-pass filtering
ErrorSpanFilterTest.java 3 tests
DurationSpanFilterTest.java 3 tests
TraceDurationFilterTest.java 5 tests
FilteringSpanExporterTest.java 13 tests (composition, grouping, metrics, delegation)
README.md Documentation

Test plan

  • ErrorSpanFilterTest — error, OK, UNSET status codes
  • DurationSpanFilterTest — over/at/under threshold boundary cases
  • TraceDurationFilterTest — over/at/under threshold, single-span trace
  • FilteringSpanExporterTest — trace grouping, mixed traces, span-only/trace-only/custom filter composition, dropped-span metrics, flush/shutdown delegation, empty batch
  • Spotless formatting passes
  • Compiles with NullAway and Error Prone checks

…interfaces

Adds a trace-level filtering span exporter to the processors module.
FilteringSpanExporter wraps a delegate SpanExporter and only forwards
spans belonging to traces that match at least one configured filter.
Filtering is trace-aware: if any filter matches, all spans sharing
that trace ID are exported together.

Two filter interfaces enable composable filtering:
- SpanFilter: per-span evaluation (e.g., error status, slow duration)
- TraceFilter: batch-level evaluation (e.g., overall trace wall-clock duration)

Built-in implementations:
- ErrorSpanFilter: keeps traces with error spans
- DurationSpanFilter: keeps traces with slow individual spans
- TraceDurationFilter: keeps traces with long wall-clock duration

Optional Meter parameter emits a dropped-span counter with reason attribute.
@udaysagar2177 udaysagar2177 requested a review from a team as a code owner April 7, 2026 08:14
Copilot AI review requested due to automatic review settings April 7, 2026 08:14
@linux-foundation-easycla
Copy link
Copy Markdown

linux-foundation-easycla bot commented Apr 7, 2026

CLA Signed

The committers listed above are authorized under a signed CLA.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a FilteringSpanExporter with composable SpanFilter and TraceFilter interfaces for trace-level filtering in the OpenTelemetry Java contrib project. The feature enables services with heavy sampling to prioritize exporting traces containing errors or exhibiting unusual latency patterns. The implementation includes a core exporter wrapper that groups spans by trace ID and makes filtering decisions at the trace level, along with three built-in filter implementations for common use cases.

Changes:

  • Adds two new filter interfaces (SpanFilter, TraceFilter) for composable, user-extensible filtering
  • Implements three built-in filters (ErrorSpanFilter, DurationSpanFilter, TraceDurationFilter) for common scenarios
  • Implements FilteringSpanExporter with single-pass filtering logic that maintains trace-level grouping
  • Includes optional metrics emission for observability into dropped spans
  • Adds comprehensive test coverage (24 tests total) covering composition, grouping, edge cases, and metrics
  • Updates README documentation with usage examples

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
SpanFilter.java Interface for per-span filtering evaluation
TraceFilter.java Interface for batch-level trace filtering
ErrorSpanFilter.java Built-in filter for error status detection
DurationSpanFilter.java Built-in filter for individual span duration thresholds
TraceDurationFilter.java Built-in filter for trace-level wall-clock duration thresholds
FilteringSpanExporter.java Core exporter wrapper with trace-aware filtering logic
*Test.java Comprehensive unit tests for all components
processors/README.md Documentation and usage examples

Copy link
Copy Markdown
Contributor

@LikeTheSalad LikeTheSalad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thank you 🙏

}

@Override
public boolean shouldKeep(String traceId, Collection<SpanData> spans) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public boolean shouldKeep(String traceId, Collection<SpanData> spans) {
public boolean shouldKeep(String traceId, Collection<SpanData> spans) {
if (spans.isEmpty()) {
return false;
}

we can add an early return for an empty list of spans otherwise you end up with maxEnd - minStart = Long.MIN_VALUE - Long.MAX_VALUE = 1 down on line 42 and it'll return true.

Related test that currently would fail:

  @Test
  void worksWithEmptyList() {
    TraceDurationFilter filter = new TraceDurationFilter(0);
    assertThat(filter.shouldKeep("trace-1", emptyList())).isFalse();
  }

Comment on lines +64 to +66
this.delegate = delegate;
this.spanFilters = Collections.unmodifiableList(new ArrayList<>(spanFilters));
this.traceFilters = Collections.unmodifiableList(new ArrayList<>(traceFilters));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we do any kind of validation here?

Suggested change
this.delegate = delegate;
this.spanFilters = Collections.unmodifiableList(new ArrayList<>(spanFilters));
this.traceFilters = Collections.unmodifiableList(new ArrayList<>(traceFilters));
this.delegate = Objects.requireNonNull(delegate, "delegate");
Objects.requireNonNull(spanFilters, "spanFilters");
Objects.requireNonNull(traceFilters, "traceFilters");
this.spanFilters = Collections.unmodifiableList(new ArrayList<>(spanFilters));
this.traceFilters = Collections.unmodifiableList(new ArrayList<>(traceFilters));

* duration strictly greater than this are considered interesting
*/
public TraceDurationFilter(long thresholdMs) {
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);
if (thresholdMs < 0) {
throw new IllegalArgumentException("thresholdMs must be non-negative, got: " + thresholdMs);
}
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);

Also, same comment as DurationSpanFilter about using Duration instead of long

* than this are considered interesting
*/
public DurationSpanFilter(long thresholdMs) {
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should validate the input here

Suggested change
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);
if (thresholdMs < 0) {
throw new IllegalArgumentException("thresholdMs must be non-negative, got: " + thresholdMs);
}
this.thresholdNanos = TimeUnit.MILLISECONDS.toNanos(thresholdMs);

Also, perhaps it would make the API a bit more intuitive if we accepted a Duration instead of a long? new DurationSpanFilter(Duration.ofSeconds(2)) feels more intuitive than new DurationSpanFilter(2000)

if (meter != null) {
this.droppedSpansCounter =
meter
.counterBuilder("filtering.span.exporter.dropped")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the sdk they have metrics that look like otel.sdk.exporter.span.exported and otel.sdk.processor.span.processed, so perhaps we should use something closer to that pattern like otel.contrib.exporter.span.filtered or otel.contrib.processor.span.dropped? Or maybe having contrib doesn't make sense in this context

@trask wdyt?

Suggested change
.counterBuilder("filtering.span.exporter.dropped")
.counterBuilder("otel.contrib.processor.span.filtered")

this.droppedSpansCounter =
meter
.counterBuilder("filtering.span.exporter.dropped")
.setDescription("Number of spans dropped by the filtering span exporter")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.setDescription("Number of spans dropped by the filtering span exporter")
.setDescription("Number of spans dropped by the filtering span exporter")
.setUnit("{span}")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants