Trace Tail Sampling
Trace tail sampling discards traces after they have been stored, during BanyanDB’s own compaction. Because the whole trace is on disk by the time the decision is made, the decision can consider all of it — total duration, whether any span failed, which tags appear anywhere in it — and it reclaims space that has already been written.
This is BanyanDB-only: it runs inside the data node, not in OAP. For the ingest-side mechanism that samples segments as they arrive, see Trace Sampling. The two are independent gates, so enabling both multiplies the drop rate.
For the full list of configuration keys, see the
BanyanDB storage guide. The samplers’ source, and the
authoritative reference for their behaviour, is
plugins/README.md
in the BanyanDB repository.
Requirements
Tail sampling is disabled by default, and enabling it in bydb.yml is not enough on its
own. It only takes effect where all of the following hold:
- The data node runs the plugin-capable BanyanDB image (the
-pluginstag). Go plugins need a CGO-enabled, dynamically linked host, so the default image cannot load them. - The node is started with
-trace-pipeline-native-plugin-enabled=trueand-trace-pipeline-trusted-plugin-dirpointing at a directory. - The sampler
.sois present in that directory, usually by mounting the-plugins-carrierimage at deploy time. storage.banyandb.<group>.pipeline.enabledistrueinbydb.yml.
Anything short of that is safe rather than broken:
- A node without plugin support ignores the pipeline config entirely — no log, no effect.
- A node that has support but cannot load the
.sologs an error, keeps its previous sampler set and merges unfiltered. Nothing is dropped because a plugin is missing.
How a trace is judged
Two first-party samplers ship with BanyanDB, one per trace schema: sw-trace-sampler.so for
SkyWalking’s native segments and zipkin-trace-sampler.so for Zipkin spans. Both run the same
rules and differ only in which columns hold the inputs.
Rules are OR-ed and evaluated in order; the first match keeps the trace and the rest are skipped:
- Duration — the trace’s end-to-end envelope reaches
durationThresholdMs. - Errors —
keepErrorsis set and the trace carries an error. - Tag rules — any
keepTagRulesentry matches a searchable tag. - Healthy sample — a deterministic fraction of whatever is left.
A trace matching none of them is deleted at merge.
Order affects cost, never the verdict. The duration test reads two numeric columns, while the tag rules require decoding the flattened tag array, so a trace kept on duration is roughly ten times cheaper to judge than one that falls through to the tag rules.
Duration is an envelope, not a per-span test
envelope = max(start + duration) − min(start) over every row of the trace
keep if envelope ≥ durationThresholdMs
This catches traces that are slow only through sequential work: three chained 400 ms calls have no single span above 400 ms, but an envelope of 1.2 s, so a 1000 ms threshold keeps that trace. A per-span maximum would miss it.
It is deliberately not BanyanDB’s intrinsic MaxTS − MinTS. Those are both derived from the
per-row timestamp column, so they measure the spread of span start times — which ignores how
long the final span ran, and is 0 for a single-span trace.
The healthy sample
Whatever reaches rule 4 is, by definition, fast and error-free. healthySampleRate keeps a
fraction of it so the group retains a baseline of ordinary traffic rather than only outliers:
keep if fnv1a(trace_id) mapped into [0,1) < healthySampleRate
0.1 keeps 10%, 0 keeps none, 1.0 keeps all. Two properties follow from hashing the trace
ID rather than drawing a random number:
- It is stable. A trace is judged more than once — at every merge it takes part in, and again at finalization if that event is enabled. A random draw would give it a fresh chance each time and its survival odds would decay with every pass. Hashing means the retained fraction is a fixed subset.
- It is not stratified. The kept fraction is an arbitrary slice of trace-ID space, not
balanced across services, endpoints or time. “Always keep everything from the payment
service” has to be a
keepTagRulesentry; the sample rate cannot express it.
The rate is statistical, not a quota: over a million traces the realised share lands within a fraction of a percent, but over ten traces it may be none of them.
Tag rules match searchable tags only
OAP does not store searchable tags as individual columns. It flattens every one of them into a
single string array per trace — tags for segments, query for Zipkin — holding key=value
entries. Every keepTagRules entry is matched against that array.
A rule naming a first-class column (service_id, local_endpoint_service_name, …) therefore
could never match, and the samplers reject such a rule at startup rather than let it
silently never fire. So “keep everything from the payment service” has to key off a tag the
instrumentation actually emits, not the service column. If a searchable tag happens to share a
name with a column, match it through the array itself: {tagKey: query, regex: "^duration="}.
What each schema stores where
| Input | sw-trace-sampler |
zipkin-trace-sampler |
|---|---|---|
| Searchable tags | tags |
query |
| Error signal | is_error column |
error key inside query |
| Per-row start | start_time |
timestamp_millis |
| Per-row duration | latency (milliseconds) |
duration (microseconds) |
Despite its name, timestamp_millis is a BanyanDB timestamp column, so both schemas supply the
start time in nanoseconds. Only the duration units differ, and the plugin normalizes them, so
durationThresholdMs is milliseconds on both.
When the decision is made
enabledEvents selects which events run the chain:
PIPELINE_EVENT_MERGE— during Hot-phase compaction. This is the default: an empty value falls back to it for backward compatibility.PIPELINE_EVENT_FINALIZE— a background sweep once a segment has settled. It must be named explicitly; an empty value never enables it.
MERGE alone is best-effort. A trace is only judged if it happens to take part in a compaction,
and compaction is driven by part count, so a trace in a shard that never reaches the merge
threshold is never evaluated and survives to TTL. FINALIZE is the backstop: it force-merges
un-finalized parts through the same chain so coverage does not depend on compaction luck, at the
cost of extra background work.
Grace windows
Neither event judges a trace immediately. mergeGraceSeconds and finalizeGraceSeconds are
maturity windows measured against the trace’s own data timestamps, so a trace whose remaining
spans are still arriving is not destroyed half-written.
Only a positive value overrides the data node’s default. -1 means “not set here”, leaving
the node’s own default (30s merge, 5m finalize) in force. 0 does not mean “no grace”: the
node treats any non-positive value as unset, so a zero grace can only be set with the node’s own
-trace-pipeline-merge-grace-default flag.
mergeGraceSeconds ships as an explicit 30 minutes rather than inheriting the node’s 30s,
because a trace is usually read soon after it is written and the shorter window would let the
sampler delete traces while someone is still looking at them.
Failing open
Every path is biased towards keeping data:
- A predicate that cannot be evaluated keeps the trace. An absent duration or error column means “can’t tell”, not “not slow” / “no error” — those columns are part of the schema, so their absence means the block was written under a different one, usually the wrong plugin attached to the group.
- A plugin that panics, errors, times out, or returns a mask of the wrong length is bypassed and the merge runs unfiltered.
- A plugin that fails to load leaves the previous good sampler set in place.
Two cases are deliberately not treated as “can’t tell”, because they are ordinary data: a trace carrying no searchable tags simply matches no tag rule, and an error column that is present but not truthy really does mean “not an error”.
Operating it
Watch these on the data node’s own metrics endpoint:
| Metric | Meaning |
|---|---|
banyandb_trace_pipeline_sampler_active_count{group} |
samplers currently loaded for the group; 0 means nothing is filtering |
banyandb_trace_pipeline_sampler_load_failed{group,name,reason} |
a plugin was rejected — non-zero means the config is not doing what it says |
banyandb_trace_pipeline_sampler_register_total{group,result} |
registration outcomes, including rejected |
Expect a CPU cost on merge. BanyanDB can normally copy a single-block trace through a merge as raw bytes without decoding it. A sampler that projects any tag column — which every useful configuration does — disables that fast path, so blocks are decoded in full during merges that run the filter. The reward is that dropped traces are never re-written, reclaiming the space rather than leaving tombstones.
Limitations
keepErrorson Zipkin reads a tag convention, not an authoritative field. The Zipkin schema has nois_errorcolumn, so the sampler looks for Zipkin’s conventionalerrortag among the flattenedqueryentries — OAP writes each span tag there both as the bare key (error) and askey=value(error=<message>), and a match on either keeps the trace. Instrumentation that signals failure only throughhttp.status_code5xx orotel.status_codewrites noerrortag at all, so those traces need an explicitkeepTagRulesentry.- Even that is lost when the error value exceeds 256 characters. OAP skips both forms —
the bare key and
key=value— when either exceedsTag.TAG_LENGTH, so anerrortag carrying a long exception message reachesqueryin neither form andkeepErrorscannot see it. The loudest errors are the ones that go missing. Catch those with akeepTagRulesentry on a short-valued tag, such as anhttp.status_coderegex.