Add FilteringSpanExporter with composable SpanFilter and TraceFilter interfaces#2745
Add FilteringSpanExporter with composable SpanFilter and TraceFilter interfaces#2745udaysagar2177 wants to merge 2 commits intoopen-telemetry:mainfrom
Conversation
…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.
There was a problem hiding this comment.
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
FilteringSpanExporterwith 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 |
| } | ||
|
|
||
| @Override | ||
| public boolean shouldKeep(String traceId, Collection<SpanData> spans) { |
There was a problem hiding this comment.
| 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();
}| this.delegate = delegate; | ||
| this.spanFilters = Collections.unmodifiableList(new ArrayList<>(spanFilters)); | ||
| this.traceFilters = Collections.unmodifiableList(new ArrayList<>(traceFilters)); |
There was a problem hiding this comment.
should we do any kind of validation here?
| 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); |
There was a problem hiding this comment.
| 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); |
There was a problem hiding this comment.
we should validate the input here
| 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") |
There was a problem hiding this comment.
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?
| .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") |
There was a problem hiding this comment.
| .setDescription("Number of spans dropped by the filtering span exporter") | |
| .setDescription("Number of spans dropped by the filtering span exporter") | |
| .setUnit("{span}") |
Summary
Adds a trace-level filtering
SpanExporterto theprocessorsmodule.FilteringSpanExporterwraps 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:
ErrorSpanFilterSpanFilterStatusCode.ERRORDurationSpanFilterSpanFilterTraceDurationFilterTraceFilterAn optional
Meterparameter emits afiltering.span.exporter.droppedcounter with areasonattribute 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
InterceptableSpanExporterin this module operates per-span via theInterceptorinterface, but has no trace-level grouping.FilteringSpanExporterfills this gap by grouping spans by trace ID and making keep/drop decisions at the trace level.Usage
Changes
SpanFilter.javaTraceFilter.javaErrorSpanFilter.javaDurationSpanFilter.javaTraceDurationFilter.javaFilteringSpanExporter.javaErrorSpanFilterTest.javaDurationSpanFilterTest.javaTraceDurationFilterTest.javaFilteringSpanExporterTest.javaREADME.mdTest plan
ErrorSpanFilterTest— error, OK, UNSET status codesDurationSpanFilterTest— over/at/under threshold boundary casesTraceDurationFilterTest— over/at/under threshold, single-span traceFilteringSpanExporterTest— trace grouping, mixed traces, span-only/trace-only/custom filter composition, dropped-span metrics, flush/shutdown delegation, empty batch