|
| 1 | +# Trace Tail Sampling |
| 2 | + |
| 3 | +Trace tail sampling discards traces **after** they have been stored, during BanyanDB's own |
| 4 | +compaction. Because the whole trace is on disk by the time the decision is made, the decision |
| 5 | +can consider all of it — total duration, whether any span failed, which tags appear anywhere in |
| 6 | +it — and it reclaims space that has already been written. |
| 7 | + |
| 8 | +This is BanyanDB-only: it runs inside the data node, not in OAP. For the ingest-side mechanism |
| 9 | +that samples segments as they arrive, see [Trace Sampling](../setup/backend/trace-sampling.md). |
| 10 | +The two are independent gates, so enabling both multiplies the drop rate. |
| 11 | + |
| 12 | +For the full list of configuration keys, see the |
| 13 | +[BanyanDB storage guide](../setup/backend/storages/banyandb.md). The samplers' source, and the |
| 14 | +authoritative reference for their behaviour, is |
| 15 | +[`plugins/README.md`](https://github.com/apache/skywalking-banyandb/blob/main/plugins/README.md) |
| 16 | +in the BanyanDB repository. |
| 17 | + |
| 18 | +## Requirements |
| 19 | + |
| 20 | +Tail sampling is **disabled by default**, and enabling it in `bydb.yml` is not enough on its |
| 21 | +own. It only takes effect where all of the following hold: |
| 22 | + |
| 23 | +1. The data node runs the **plugin-capable** BanyanDB image (the `-plugins` tag). Go plugins |
| 24 | + need a CGO-enabled, dynamically linked host, so the default image cannot load them. |
| 25 | +2. The node is started with `-trace-pipeline-native-plugin-enabled=true` and |
| 26 | + `-trace-pipeline-trusted-plugin-dir` pointing at a directory. |
| 27 | +3. The sampler `.so` is present in that directory, usually by mounting the |
| 28 | + `-plugins-carrier` image at deploy time. |
| 29 | +4. `storage.banyandb.<group>.pipeline.enabled` is `true` in `bydb.yml`. |
| 30 | + |
| 31 | +Anything short of that is safe rather than broken: |
| 32 | + |
| 33 | +- A node without plugin support **ignores** the pipeline config entirely — no log, no effect. |
| 34 | +- A node that has support but cannot load the `.so` logs an error, keeps its previous sampler |
| 35 | + set and merges **unfiltered**. Nothing is dropped because a plugin is missing. |
| 36 | + |
| 37 | +## How a trace is judged |
| 38 | + |
| 39 | +Two first-party samplers ship with BanyanDB, one per trace schema: `sw-trace-sampler.so` for |
| 40 | +SkyWalking's native segments and `zipkin-trace-sampler.so` for Zipkin spans. Both run the same |
| 41 | +rules and differ only in which columns hold the inputs. |
| 42 | + |
| 43 | +Rules are **OR-ed** and evaluated in order; the first match keeps the trace and the rest are |
| 44 | +skipped: |
| 45 | + |
| 46 | +1. **Duration** — the trace's end-to-end envelope reaches `durationThresholdMs`. |
| 47 | +2. **Errors** — `keepErrors` is set and the trace carries an error. |
| 48 | +3. **Tag rules** — any `keepTagRules` entry matches a searchable tag. |
| 49 | +4. **Healthy sample** — a deterministic fraction of whatever is left. |
| 50 | + |
| 51 | +A trace matching none of them is deleted at merge. |
| 52 | + |
| 53 | +Order affects cost, never the verdict. The duration test reads two numeric columns, while the |
| 54 | +tag rules require decoding the flattened tag array, so a trace kept on duration is roughly ten |
| 55 | +times cheaper to judge than one that falls through to the tag rules. |
| 56 | + |
| 57 | +### Duration is an envelope, not a per-span test |
| 58 | + |
| 59 | +``` |
| 60 | +envelope = max(start + duration) − min(start) over every row of the trace |
| 61 | +keep if envelope ≥ durationThresholdMs |
| 62 | +``` |
| 63 | + |
| 64 | +This catches traces that are slow only through *sequential* work: three chained 400 ms calls |
| 65 | +have no single span above 400 ms, but an envelope of 1.2 s, so a 1000 ms threshold keeps that |
| 66 | +trace. A per-span maximum would miss it. |
| 67 | + |
| 68 | +It is deliberately **not** BanyanDB's intrinsic `MaxTS − MinTS`. Those are both derived from the |
| 69 | +per-row timestamp column, so they measure the spread of span *start* times — which ignores how |
| 70 | +long the final span ran, and is `0` for a single-span trace. |
| 71 | + |
| 72 | +### The healthy sample |
| 73 | + |
| 74 | +Whatever reaches rule 4 is, by definition, fast and error-free. `healthySampleRate` keeps a |
| 75 | +fraction of it so the group retains a baseline of ordinary traffic rather than only outliers: |
| 76 | + |
| 77 | +``` |
| 78 | +keep if fnv1a(trace_id) mapped into [0,1) < healthySampleRate |
| 79 | +``` |
| 80 | + |
| 81 | +`0.1` keeps 10%, `0` keeps none, `1.0` keeps all. Two properties follow from hashing the trace |
| 82 | +ID rather than drawing a random number: |
| 83 | + |
| 84 | +- **It is stable.** A trace is judged more than once — at every merge it takes part in, and |
| 85 | + again at finalization if that event is enabled. A random draw would give it a fresh chance |
| 86 | + each time and its survival odds would decay with every pass. Hashing means the retained |
| 87 | + fraction is a fixed subset. |
| 88 | +- **It is not stratified.** The kept fraction is an arbitrary slice of trace-ID space, not |
| 89 | + balanced across services, endpoints or time. "Always keep everything from the payment |
| 90 | + service" has to be a `keepTagRules` entry; the sample rate cannot express it. |
| 91 | + |
| 92 | +The rate is statistical, not a quota: over a million traces the realised share lands within a |
| 93 | +fraction of a percent, but over ten traces it may be none of them. |
| 94 | + |
| 95 | +### Tag rules match searchable tags only |
| 96 | + |
| 97 | +OAP does not store searchable tags as individual columns. It flattens every one of them into a |
| 98 | +single string array per trace — `tags` for segments, `query` for Zipkin — holding `key=value` |
| 99 | +entries. Every `keepTagRules` entry is matched against that array. |
| 100 | + |
| 101 | +A rule naming a first-class column (`service_id`, `local_endpoint_service_name`, …) therefore |
| 102 | +could never match, and the samplers **reject** such a rule at startup rather than let it |
| 103 | +silently never fire. So "keep everything from the payment service" has to key off a tag the |
| 104 | +instrumentation actually emits, not the service column. If a searchable tag happens to share a |
| 105 | +name with a column, match it through the array itself: `{tagKey: query, regex: "^duration="}`. |
| 106 | + |
| 107 | +### What each schema stores where |
| 108 | + |
| 109 | +| Input | `sw-trace-sampler` | `zipkin-trace-sampler` | |
| 110 | +|---|---|---| |
| 111 | +| Searchable tags | `tags` | `query` | |
| 112 | +| Error signal | `is_error` column | `error` key inside `query` | |
| 113 | +| Per-row start | `start_time` | `timestamp_millis` | |
| 114 | +| Per-row duration | `latency` (milliseconds) | `duration` (**micro**seconds) | |
| 115 | + |
| 116 | +Despite its name, `timestamp_millis` is a BanyanDB timestamp column, so both schemas supply the |
| 117 | +start time in nanoseconds. Only the duration units differ, and the plugin normalizes them, so |
| 118 | +`durationThresholdMs` is milliseconds on both. |
| 119 | + |
| 120 | +## When the decision is made |
| 121 | + |
| 122 | +`enabledEvents` selects which events run the chain: |
| 123 | + |
| 124 | +- `PIPELINE_EVENT_MERGE` — during Hot-phase compaction. This is the default: an empty value |
| 125 | + falls back to it for backward compatibility. |
| 126 | +- `PIPELINE_EVENT_FINALIZE` — a background sweep once a segment has settled. It must be named |
| 127 | + explicitly; an empty value never enables it. |
| 128 | + |
| 129 | +`MERGE` alone is best-effort. A trace is only judged if it happens to take part in a compaction, |
| 130 | +and compaction is driven by part count, so a trace in a shard that never reaches the merge |
| 131 | +threshold is never evaluated and survives to TTL. `FINALIZE` is the backstop: it force-merges |
| 132 | +un-finalized parts through the same chain so coverage does not depend on compaction luck, at the |
| 133 | +cost of extra background work. |
| 134 | + |
| 135 | +### Grace windows |
| 136 | + |
| 137 | +Neither event judges a trace immediately. `mergeGraceSeconds` and `finalizeGraceSeconds` are |
| 138 | +maturity windows measured against the trace's own data timestamps, so a trace whose remaining |
| 139 | +spans are still arriving is not destroyed half-written. |
| 140 | + |
| 141 | +Only a **positive** value overrides the data node's default. `-1` means "not set here", leaving |
| 142 | +the node's own default (30s merge, 5m finalize) in force. `0` does **not** mean "no grace": the |
| 143 | +node treats any non-positive value as unset, so a zero grace can only be set with the node's own |
| 144 | +`-trace-pipeline-merge-grace-default` flag. |
| 145 | + |
| 146 | +`mergeGraceSeconds` ships as an explicit 30 minutes rather than inheriting the node's 30s, |
| 147 | +because a trace is usually read soon after it is written and the shorter window would let the |
| 148 | +sampler delete traces while someone is still looking at them. |
| 149 | + |
| 150 | +## Failing open |
| 151 | + |
| 152 | +Every path is biased towards keeping data: |
| 153 | + |
| 154 | +- A predicate that cannot be evaluated keeps the trace. An absent duration or error column |
| 155 | + means "can't tell", not "not slow" / "no error" — those columns are part of the schema, so |
| 156 | + their absence means the block was written under a different one, usually the wrong plugin |
| 157 | + attached to the group. |
| 158 | +- A plugin that panics, errors, times out, or returns a mask of the wrong length is bypassed |
| 159 | + and the merge runs unfiltered. |
| 160 | +- A plugin that fails to load leaves the previous good sampler set in place. |
| 161 | + |
| 162 | +Two cases are deliberately *not* treated as "can't tell", because they are ordinary data: a |
| 163 | +trace carrying no searchable tags simply matches no tag rule, and an error column that is |
| 164 | +present but not truthy really does mean "not an error". |
| 165 | + |
| 166 | +## Operating it |
| 167 | + |
| 168 | +Watch these on the data node's own metrics endpoint: |
| 169 | + |
| 170 | +| Metric | Meaning | |
| 171 | +|---|---| |
| 172 | +| `banyandb_trace_pipeline_sampler_active_count{group}` | samplers currently loaded for the group; `0` means nothing is filtering | |
| 173 | +| `banyandb_trace_pipeline_sampler_load_failed{group,name,reason}` | a plugin was rejected — non-zero means the config is not doing what it says | |
| 174 | +| `banyandb_trace_pipeline_sampler_register_total{group,result}` | registration outcomes, including `rejected` | |
| 175 | + |
| 176 | +**Expect a CPU cost on merge.** BanyanDB can normally copy a single-block trace through a merge |
| 177 | +as raw bytes without decoding it. A sampler that projects any tag column — which every useful |
| 178 | +configuration does — disables that fast path, so blocks are decoded in full during merges that |
| 179 | +run the filter. The reward is that dropped traces are never re-written, reclaiming the space |
| 180 | +rather than leaving tombstones. |
| 181 | + |
| 182 | +## Limitations |
| 183 | + |
| 184 | +- **`keepErrors` on Zipkin reads a tag convention, not an authoritative field.** The Zipkin |
| 185 | + schema has no `is_error` column, so the sampler looks for Zipkin's conventional `error` tag |
| 186 | + among the flattened `query` entries — OAP writes each span tag there both as the bare key |
| 187 | + (`error`) and as `key=value` (`error=<message>`), and a match on either keeps the trace. |
| 188 | + Instrumentation that signals failure only through `http.status_code` 5xx or `otel.status_code` |
| 189 | + writes no `error` tag at all, so those traces need an explicit `keepTagRules` entry. |
| 190 | +- **Even that is lost when the error value exceeds 256 characters.** OAP skips *both* forms — |
| 191 | + the bare key and `key=value` — when either exceeds `Tag.TAG_LENGTH`, so an `error` tag carrying |
| 192 | + a long exception message reaches `query` in neither form and `keepErrors` cannot see it. The |
| 193 | + loudest errors are the ones that go missing. Catch those with a `keepTagRules` entry on a |
| 194 | + short-valued tag, such as an `http.status_code` regex. |
0 commit comments