Skip to content

Commit 334e031

Browse files
committed
Add a BanyanDB trace tail sampling guide
The trace retention pipeline had its configuration documented in the BanyanDB storage guide, but nothing explained how a trace is actually judged. Add a page under "BanyanDB Exclusive Setup" covering it: the OR-ed rule chain and why order affects cost but never the verdict, the end-to-end duration envelope and why it is not the intrinsic MaxTS - MinTS, the deterministic trace-ID hash behind healthySampleRate and the two consequences of hashing rather than drawing at random, what each schema stores where, MERGE versus FINALIZE and their grace windows, the fail-open behaviour when a plugin is absent or unloadable, and the metrics to watch. Split the material rather than duplicate it. The storage guide keeps the keys, the environment-override guidance and the merge CPU cost, since those are what you need while deciding what to set, and its 54 lines of semantics move to the new guide. Both now cross-reference, and each fact lives in one place — the earlier duplication is where the broken settings table and the stale "a blank YAML value fails startup" claim came from. trace-sampling.md gains a See also, so a reader looking at ingest-side sampling can find the post-storage mechanism instead of assuming ingest is all there is. On the Zipkin limitations: say what keepErrors actually reads rather than calling it a convention and leaving it there. It looks for the error tag among the flattened query entries, which OAP writes both as a bare key and as key=value. That matters for the second limitation, because OAP drops BOTH forms once the value passes 256 characters, so a long exception message makes the loudest errors invisible to keepErrors.
1 parent 79cdb0b commit 334e031

5 files changed

Lines changed: 218 additions & 89 deletions

File tree

docs/en/banyandb/tail-sampling.md

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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.

docs/en/changes/changes.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@
338338
* Fix: an MQE `top_n(metric, N, order, attrX='value')` query whose attribute is not a column of the target metric now returns a descriptive MQE error instead of a raw storage `IOException` surfaced as `Internal IO exception, query metrics error.`. Attribute columns (`attr0..attrN`) exist only on decorated metrics (`service_*` / `endpoint_*` / `kubernetes_service_*`, set to the layer name via OAL `.decorator(...)`) and the MAL meter base; metrics such as relations or database / cache / mq access carry none, so passing an attribute condition previously reached the storage engine with a tag it does not define and failed there. `MQEVisitor` now validates each attribute key against the metric's registered queryable columns before the storage call and raises `IllegalExpressionException` (naming the attribute and the metric) when it is absent.
339339
* Migrate all BanyanDB storage read queries from the typed query-builder API to BydbQL.
340340
* Fix: BanyanDB queries no longer silently truncate at the storage engine's implicit row cap. BanyanDB applies its own default limit to any query that carries none — 100 rows for measures, 20 for streams/traces — and applies it *after* `GROUP BY`, so an over-long result set is cut short rather than rejected. OAP never sent a limit on several read paths, so a metrics query returned at most 100 data points regardless of the requested range: a 4-hour minute-step read rendered only its first 100 minutes and the rest showed as empty, even though `DurationUtils` allows up to 500 steps. The same cap silently shortened topology relation maps, instance/process metadata lists, profiling thread snapshots and eBPF task lists. Every BydbQL query now leaves OAP with an explicit `LIMIT`: the entity-scoped metrics read sends the exact number of assembled duration points (matching the row set the ES/JDBC DAOs fetch by id), ad-hoc `SELECT TOP` sends its own `N`, and anything that does not paginate itself falls back to the configured `resultWindowMaxSize` (default `10000`) instead of the engine default. ES and JDBC storage were never affected.
341-
* Support BanyanDB's group-scoped trace retention pipeline in `bydb.yml`. The `trace` and `zipkinTrace` groups gain a `pipeline` block (`enabled`, `enabledEvents`, `mergeGraceSeconds`, `finalizeGraceSeconds`, and an ordered `plugins` chain) that OAP pushes onto the BanyanDB group as a `TracePipelineConfig`, letting a sampler plugin drop traces inside the data node during Hot-phase compaction — after storage, so it reclaims space already written and decides per whole trace, unlike the ingest-side [server-side trace sampling](../setup/backend/trace-sampling.md). Disabled by default, since it deletes stored traces. Even when enabled it is inert unless the data node runs the plugin-capable BanyanDB image with the sampler `.so` mounted — a node without that support ignores the config, and one that cannot load the plugin logs an error and merges unfiltered, so nothing is dropped unexpectedly. The two grace windows use `-1` for "inherit the data node default" (30s merge / 5m finalize) because the node treats any non-positive grace as unset. `enabledEvents` accepts a comma-separated string so it can be set from the environment (`SW_STORAGE_BANYANDB_TRACE_PIPELINE_ENABLED_EVENTS` and its `ZIPKIN_` variant) as well as a YAML block list; an empty value falls back to `PIPELINE_EVENT_MERGE`, so `PIPELINE_EVENT_FINALIZE` runs only when named explicitly. Each plugin's `config` is passed through verbatim as a protobuf `Struct`: nested lists and objects (e.g. `keepTagRules`) now survive the config loader and are serialized as real `ListValue`/`Struct` rather than being flattened to a string. Note a float written as a `${ENV:default}` placeholder still reaches the plugin as a JSON string, because the shared placeholder resolver only preserves String/Integer/Long/Boolean; the first-party samplers accept a quoted number for exactly this reason. See [BanyanDB storage](../setup/backend/storages/banyandb.md).
341+
* Support BanyanDB's group-scoped trace retention pipeline in `bydb.yml`. The `trace` and `zipkinTrace` groups gain a `pipeline` block (`enabled`, `enabledEvents`, `mergeGraceSeconds`, `finalizeGraceSeconds`, and an ordered `plugins` chain) that OAP pushes onto the BanyanDB group as a `TracePipelineConfig`, letting a sampler plugin drop traces inside the data node during Hot-phase compaction — after storage, so it reclaims space already written and decides per whole trace, unlike the ingest-side [server-side trace sampling](../setup/backend/trace-sampling.md). Disabled by default, since it deletes stored traces. Even when enabled it is inert unless the data node runs the plugin-capable BanyanDB image with the sampler `.so` mounted — a node without that support ignores the config, and one that cannot load the plugin logs an error and merges unfiltered, so nothing is dropped unexpectedly. The two grace windows use `-1` for "inherit the data node default" (30s merge / 5m finalize) because the node treats any non-positive grace as unset. `enabledEvents` accepts a comma-separated string so it can be set from the environment (`SW_STORAGE_BANYANDB_TRACE_PIPELINE_ENABLED_EVENTS` and its `ZIPKIN_` variant) as well as a YAML block list; an empty value falls back to `PIPELINE_EVENT_MERGE`, so `PIPELINE_EVENT_FINALIZE` runs only when named explicitly. Each plugin's `config` is passed through verbatim as a protobuf `Struct`: nested lists and objects (e.g. `keepTagRules`) now survive the config loader and are serialized as real `ListValue`/`Struct` rather than being flattened to a string. Note a float written as a `${ENV:default}` placeholder still reaches the plugin as a JSON string, because the shared placeholder resolver only preserves String/Integer/Long/Boolean; the first-party samplers accept a quoted number for exactly this reason. See [Trace Tail Sampling](../banyandb/tail-sampling.md) for how a trace is judged, and [BanyanDB storage](../setup/backend/storages/banyandb.md) for the configuration keys.
342342
* Fix: a blank value in `bydb.yml` (`key:` with nothing after it) aborted OAP startup with an opaque `NullPointerException` from `java.util.Properties`, which rejects null values. The BanyanDB config loader now skips blank entries and leaves the field at its default, the same outcome as omitting the line.
343343
* Route LAL rules within a layer by their input type, so a single layer can host rules over different proto inputs. Each compiled rule now carries its effective input type (the proto class its `parsed.*` getters cast to, or `null` for parser-based / untyped rules), and `LogFilterListener` skips any rule whose type doesn't match the incoming log instead of running every rule in the layer. This fixes a latent `ClassCastException` (caught and logged per log) that fired whenever a `MESH` log of one shape reached a rule compiled for another — e.g. an Envoy TCP access log or a network-profiling `LogData` hitting the HTTP `envoy-als` rule. Adds an `envoy-als-tcp` rule (`inputType: TCPAccessLogEntry`) alongside the existing HTTP `envoy-als`; both share the `MESH` layer and each now only sees its own entry type.
344344

@@ -358,5 +358,6 @@
358358
* Fix the docker-compose quickstart: OAP healthcheck no longer calls `curl` (absent from the JRE image) and probes the query port via bash `/dev/tcp`; the Horizon UI service maps the correct container port (8081) and mounts a `horizon.yaml` (binding `0.0.0.0`, OAP URLs, demo `admin`/`admin` login) instead of non-existent `SW_*_ADDRESS` env vars.
359359
* Add PHP runtime metrics (PHM) dashboard documentation (agent setup, OAP `php-runtime` MAL rules, Horizon UI widgets).
360360
* Add Node.js runtime metrics dashboard documentation (agent setup, OAP `nodejs-runtime` MAL rules, Horizon UI widgets).
361+
* Add a BanyanDB trace tail sampling guide under "BanyanDB Exclusive Setup", covering how a trace is judged (the OR-ed rule chain, the end-to-end duration envelope rather than a per-span maximum, and the deterministic trace-ID hash behind `healthySampleRate`), what the two first-party samplers read from each trace schema, the MERGE vs FINALIZE events and their grace windows, the fail-open behaviour when a plugin is absent or unloadable, and the metrics to watch. Also document the Zipkin receiver's previously undocumented `sampleRate` and `maxSpansPerSecond` in the server-side trace sampling guide.
361362

362363
All issues and pull requests are [here](https://github.com/apache/skywalking/issues?q=milestone:11.0.0)

0 commit comments

Comments
 (0)