Skip to content

Commit ff0a78d

Browse files
authored
[processor/spanpruning] preserve outlier subtrees with multi-level detection (#49324)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description This updates how outliers are calculated to detect non-leaf outliers, such as an entire slow request that also has subspans, and preserve it. For example, given a trace that looks something like this: ``` root (1000ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) query (5ms) query (5ms) query (5ms) query (5ms) handler (50ms) label=handler-with-slow-query query (5ms) query (5ms) query (5ms) query (45ms) label=slow-query handler (600ms) label=slow-handler query (5ms) query (5ms) query (5ms) query (550ms) label=slow-query ``` The current algorithm would produce a result that looks like this: ``` root (1000ms) handler (600ms) [SUMMARY n=8] label=slow-handler query (5ms) [SUMMARY n=30] preserved_outlier_count=2 query (45ms) [PRESERVED_OUTLIER] label=slow-query query (550ms) [PRESERVED_OUTLIER] label=slow-query ``` This entirely misses the context around the slow handler vs just two slow queries. You can imagine this being especially bad if it wasn't the query that was slow, but just something else that was not traced, then no outlier would be found at all. Now the result will look like: ``` root (1000ms) handler (600ms) [PRESERVED_OUTLIER] label=slow-handler query (5ms) query (5ms) query (5ms) query (550ms) label=slow-query handler (50ms) [SUMMARY n=7] preserved_outlier_count=1 query (5ms) [SUMMARY n=27] preserved_outlier_count=1 query (45ms) [PRESERVED_OUTLIER] label=slow-query ``` So we can see that there is both a very slow handler caused by an excessively slow query, and another outlier for just a slow query, but which didn't cause the entire handler to be abnormally slow. This is done by changing how the processor works a bit, but I think it is a clearer workflow as well. First the aggregation groups are planned without outlier influence, then outlier detection runs and protects various subtrees, then finally the aggregation work is done and spans are marked for removal. <!--Describe what testing was performed and which tests were added.--> #### Testing All existing unit tests continue to pass, and a new test was added for the intermediate-span outlier case. In addition, I ran through an example trace as described above. <!--Authorship attestation. See AGENTS.md for details. AI agents must not check this box on behalf of the user; the human author must check it themselves before the PR is ready for review.--> #### Authorship - [x] I, a human, wrote this pull request description myself. <!--Please delete paragraphs that you did not use before submitting.-->
1 parent 41d4b00 commit ff0a78d

6 files changed

Lines changed: 593 additions & 194 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
7+
component: processor/span_pruning
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Preserve whole outlier subtrees instead of individual spans and detect outliers at every aggregation level, so a slow interior span (e.g. one slow handler among many) keeps its entire subtree.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [49324]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]

processor/spanpruningprocessor/README.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Spans are grouped by:
2828

2929
Parent spans are eligible for aggregation when all of their children are aggregated, they share the same name, kind, and status code, and they are not root spans.
3030

31-
Optionally, the processor can detect **duration outliers** using statistical methods (IQR or MAD) and either annotate summary spans with outlier correlations or **preserve outlier spans** as individual spans for debugging while still aggregating normal spans.
31+
Optionally, the processor can detect **duration outliers** using statistical methods (IQR or MAD) and either annotate summary spans with outlier correlations or **preserve outlier subtrees** for debugging while still aggregating normal spans. Detection runs at every aggregation level, so a slow interior span (for example one slow `handler` among many) is caught and its whole subtree is kept intact.
3232

3333
This processor is useful for reducing trace data volume while preserving meaningful information about repeated operations.
3434

@@ -141,13 +141,15 @@ processors:
141141
# Default: 5
142142
max_correlated_attributes: 5
143143

144-
# Preserve outlier spans as individual spans instead of aggregating
145-
# When true, only normal spans are aggregated; outliers remain in the trace
144+
# Preserve outlier subtrees instead of aggregating them
145+
# When true, each detected outlier and its whole subtree are kept; only
146+
# normal spans are aggregated. Detection runs at every level, so slow
147+
# interior spans (not just leaves) are preserved with their subtree.
146148
# Default: false
147149
preserve_outliers: false
148150

149-
# Maximum number of outlier spans to preserve per aggregation group
150-
# Spans are selected by most extreme duration first
151+
# Maximum number of outlier subtrees to preserve per aggregation group
152+
# Outliers are selected by most extreme duration first
151153
# 0 = preserve all detected outliers
152154
# Default: 2
153155
max_preserved_outliers: 2
@@ -184,8 +186,8 @@ processors:
184186
| `outlier_analysis.correlation_min_occurrence` | float64 | 0.75 | Minimum outlier occurrence fraction for correlation |
185187
| `outlier_analysis.correlation_max_normal_occurrence` | float64 | 0.25 | Maximum normal occurrence fraction for correlation |
186188
| `outlier_analysis.max_correlated_attributes` | int | 5 | Maximum correlated attributes to report |
187-
| `outlier_analysis.preserve_outliers` | bool | false | Keep outliers as individual spans instead of aggregating |
188-
| `outlier_analysis.max_preserved_outliers` | int | 2 | Max outliers to preserve per group (0=preserve all) |
189+
| `outlier_analysis.preserve_outliers` | bool | false | Keep outlier subtrees instead of aggregating |
190+
| `outlier_analysis.max_preserved_outliers` | int | 2 | Max outlier subtrees to preserve per group (0=preserve all) |
189191
| `outlier_analysis.preserve_only_with_correlation` | bool | false | Only preserve outliers if a strong correlation is found |
190192
| `outlier_analysis.min_outlier_threshold_percent` | float64 | 0.1 | Minimum percentage above median required before a span is considered an outlier |
191193

@@ -296,10 +298,10 @@ Preserved outlier spans are annotated with:
296298
| `<prefix>is_preserved_outlier` | bool | Identifies span as a preserved outlier |
297299
| `<prefix>summary_span_id` | string | SpanID of the associated summary span |
298300

299-
A preserved outlier becomes a sibling of its summary span. Grouping is
300-
depth-aware — leaves and parents are never grouped with same-named ancestors at
301-
a different depth — so an outlier stays at its original depth and the summary it
302-
links to (via `summary_span_id`) sits where its group was.
301+
A preserved outlier (the root of its subtree) becomes a sibling of its summary
302+
span, and its whole subtree moves with it. The outlier keeps everything beneath it,
303+
but its aggregated ancestors are replaced by the summary it now hangs from
304+
(linked via `summary_span_id`).
303305

304306
### Histogram Buckets
305307

@@ -401,13 +403,15 @@ This helps identify root causes of latency issues:
401403
- **Minimal when disabled**: Zero overhead (no sorting or calculations)
402404
- **Recommended**: Use `min_group_size: 7` or higher to skip analysis on small groups
403405
404-
### Preserving Outlier Spans (Optional)
406+
### Preserving Outlier Subtrees (Optional)
405407
406-
When `outlier_analysis.preserve_outliers: true`, detected outlier spans are **kept as individual spans** instead of being aggregated. This provides:
408+
When `outlier_analysis.preserve_outliers: true`, each detected outlier is **kept along with its whole subtree** instead of being aggregated. A leaf outlier (e.g. a slow query) is the degenerate single-span case; an interior outlier (e.g. a slow `handler`) keeps every span beneath it. This provides:
407409
408-
- **Full visibility** into slow operations for debugging
409-
- **Preserved context**: Original attributes, events, and links remain intact
410-
- **Selective aggregation**: Only prune repetitive normal spans
410+
- **Full visibility** into slow operations for debugging, including everything the slow span did
411+
- **Preserved context**: original attributes, events, links, and tree structure remain intact
412+
- **Selective aggregation**: only prune repetitive normal spans
413+
414+
Preservation keeps everything **beneath** an outlier, not above it: the outlier's subtree is kept, but its ancestors still aggregate normally. The preserved subtree is reparented as a sibling of its summary span (the whole subtree moves with its root), so a slow leaf ends up under the same summary its normal siblings collapsed into, and a slow interior span hangs off the summary that replaced its peers.
411415
412416
#### Configuration
413417
@@ -425,8 +429,8 @@ processors:
425429

426430
| Field | Type | Default | Description |
427431
|-------|------|---------|-------------|
428-
| `preserve_outliers` | bool | false | Keep outliers as individual spans instead of aggregating |
429-
| `max_preserved_outliers` | int | 2 | Max outliers to preserve per group (0=preserve all detected) |
432+
| `preserve_outliers` | bool | false | Keep outlier subtrees instead of aggregating |
433+
| `max_preserved_outliers` | int | 2 | Max outlier subtrees to preserve per group (0=preserve all detected) |
430434
| `preserve_only_with_correlation` | bool | false | Only preserve outliers if a strong attribute correlation is found |
431435

432436
#### Example Output
@@ -464,9 +468,11 @@ handler
464468

465469
#### Behavior Notes
466470

467-
- **Parent aggregation**: Parents can still be aggregated if all their children are either aggregated or preserved as outliers
468-
- **Skip aggregation**: If preserving outliers leaves too few normal spans (below `min_spans_to_aggregate`), the entire group is left unchanged
469-
- **Selection order**: Outliers are preserved starting with the most extreme (longest duration) first
471+
- **Multi-level detection**: outliers are detected within each sibling group at every level (leaf groups and eligible parent groups) that meets `min_group_size`.
472+
- **Subtree, not ancestors**: a preserved outlier keeps everything beneath it (its whole subtree), but its ancestors still aggregate; the subtree is reparented as a sibling of its summary.
473+
- **Nested outliers**: an outlier already inside a preserved subtree is not preserved (or counted) again, since the enclosing subtree already keeps it.
474+
- **Skip aggregation**: if protection leaves a group below `min_spans_to_aggregate`, that group is left unchanged.
475+
- **Selection order**: outliers are preserved starting with the most extreme (longest duration) first, capped by `max_preserved_outliers` subtrees per group.
470476

471477
## Pipeline Placement
472478

@@ -617,7 +623,7 @@ The processor emits the following metrics to help monitor its operation:
617623
| `otelcol_processor_spanpruning_aggregations_created` | Total number of aggregation summary spans created |
618624
| `otelcol_processor_spanpruning_traces_processed` | Total number of traces processed |
619625
| `otelcol_processor_spanpruning_outliers_detected` | Total spans identified as outliers by analysis (when `enable_outlier_analysis: true`) |
620-
| `otelcol_processor_spanpruning_outliers_preserved` | Total outlier spans kept as individual spans (when `preserve_outliers: true`) |
626+
| `otelcol_processor_spanpruning_outliers_preserved` | Total outlier spans kept (when `preserve_outliers: true`) |
621627
| `otelcol_processor_spanpruning_outliers_correlations_detected` | Total aggregation groups where outliers had correlated attributes |
622628
| `otelcol_processor_spanpruning_bytes_received` | Total bytes of serialized traces received before pruning (when `enable_bytes_metrics: true`) |
623629
| `otelcol_processor_spanpruning_bytes_processed_input` | Total bytes of serialized traces in the matched subset, measured before pruning (when `enable_bytes_metrics: true`) |

0 commit comments

Comments
 (0)