This is an automated email from the ASF dual-hosted git repository.

wankai123 pushed a commit to branch feat/first-party-trace-sampler-plugins
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit 96955876036f325ad320a6a8a1723a3618070d29
Author: wankai123 <[email protected]>
AuthorDate: Wed Jul 29 10:29:51 2026 +0800

    feat(trace-pipeline): add first-party sw/zipkin trace-retention sampler 
plugins
    
    Add sw-trace-sampler.so and zipkin-trace-sampler.so under 
plugins/skywalking/,
    implementing the Scenario 6.1/6.2 keep logic against SkyWalking's two real 
trace
    schemas. Both compile the shared internal/tracesampler engine and differ 
only in
    a Schema value naming where each schema keeps the rule inputs, so the two 
mains
    stay a few lines each and cannot drift apart.
    
    The existing _example/segment-tail-sampler projects tag names the real 
schemas do
    not have (duration/status), so it cannot filter live data. These plugins 
read the
    columns OAP actually writes: searchable tags are not first-class columns but
    "key=value" entries flattened into one string array (tags for segments, 
query for
    Zipkin), so every keepTagRules entry resolves there.
    
    Keep rules: durationThresholdMs (end-to-end envelope
    max(start + duration) - min(start), which catches traces slow only through
    sequential spans, and is not the intrinsic MaxTS-MinTS -- that is the 
spread of
    per-row start timestamps and 0 for a single-row trace), keepErrors with an
    errorTag override, keepTagRules, and a deterministic FNV-1a 
healthySampleRate
    that is stable across the merge and finalize passes so a trace is never
    half-kept.
    
    Config handling is strict by design, because every option is a keep rule 
and a
    key that silently missed would leave a sampler that drops the entire group:
    unknown and empty configs are rejected, as is a rule naming a first-class 
column
    (which could never match) -- with the array-column workaround named in the 
error.
    keepTagRules also accepts a compact "key=value,key=~regex,key" string so a 
rule
    set fits in an environment variable.
    
    Any predicate that cannot be evaluated keeps the trace. Absent duration 
columns
    mean "can't tell", not "not slow": the columns are schema-declared, so their
    absence implies the block was written under a different schema (typically 
the
    wrong plugin attached to the group), and dropping there would silently 
discard
    exactly what the operator configured the sampler to keep. An absent tag 
array is
    deliberately not treated that way, since a trace carrying no searchable 
tags is
    ordinary data.
    
    The flattened array is decoded once per trace, from a copy. The SDK's 
string-array
    decode rewrites its source in place and the engine hands the same 
TraceBatch to
    every link of a chain, so decoding the shared buffer corrupted the value 
for later
    links -- making a rule's verdict depend on its position in the chain, and 
only for
    values containing "|" or "\".
    
    Also sync docs/design/post-trace-pipeline.md: the 6.1/6.2 scenarios now 
reference
    these plugins and their real config keys instead of samplers that do not 
exist,
    and no longer claim the merge raw fast path is preserved -- projecting any 
tag
    sets forceSlow, so the block is decoded in full.
---
 CHANGES.md                                         |   1 +
 docs/design/post-trace-pipeline.md                 |  87 ++-
 plugins/README.md                                  |  74 ++
 .../skywalking/internal/tracesampler/sampler.go    | 763 +++++++++++++++++++++
 .../internal/tracesampler/sampler_test.go          | 631 +++++++++++++++++
 plugins/skywalking/sw-trace-sampler/main.go        |  71 ++
 plugins/skywalking/sw-trace-sampler/main_test.go   |  93 +++
 plugins/skywalking/zipkin-trace-sampler/main.go    |  77 +++
 .../skywalking/zipkin-trace-sampler/main_test.go   | 118 ++++
 9 files changed, 1871 insertions(+), 44 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 95dbdbf6d..bdc83104a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -76,6 +76,7 @@ Release Notes.
 - Register/update/remove in-merge trace retention samplers at runtime without 
restarting the data node, replacing the static `--trace-pipeline-config` boot 
loader (now removed). The sampler pipeline config is carried on 
`common.v1.Group` (new optional `pipeline` field, with 
`TracePipelineConfig`/`SamplerPlugin` relocated from `pipeline/v1` into the 
leaf `common/v1` to avoid a `common`→`pipeline` import cycle) and delivered 
through the existing `KindGroup` schema watch; the data node rebu [...]
 - Add FODC memory-pressure pprof capture: agent grabs heap/goroutine profiles 
when RSS nears the cgroup limit, served via the proxy.
 - Add bounded plugin telemetry (meter + logger) to the trace-pipeline sampler 
SDK. A sampler plugin opts in by implementing `sdk.HostAware`; the engine calls 
`UseHost(h)` once per group before the first `Decide`, delivering a scoped 
`sdk.Host` the plugin may cache and use in `Decide`. The host enforces all 
resource limits: metric names are forced under the 
`banyandb_trace_pipeline_plugin_` prefix; `{group, plugin_name}` labels are 
always present; per-plugin cardinality is capped at 100 s [...]
+- Add the first-party trace-retention sampler plugins `sw-trace-sampler.so` 
and `zipkin-trace-sampler.so` (`plugins/skywalking/`), implementing the 
Scenario 6.1/6.2 keep logic on SkyWalking's two trace schemas. Both compile the 
shared `plugins/skywalking/internal/tracesampler` engine and differ only in a 
`Schema` value naming where each schema keeps the rule inputs: the flattened 
searchable-tag array (`tags` for segments, `query` for Zipkin), the error 
signal (a real `is_error` column vs [...]
 - Add a Claude/Codex plugin packaging the BanyanDB MCP server with a 
parse-only `validate_bydbql` tool (backed by the `mcp/tools/bydbql-parse` Go 
validator) and a BydbQL skill for read-only natural-language-to-BydbQL 
generation over STREAM/MEASURE/TRACE/PROPERTY resources.
 - Introduce positional parameter binding (`?` placeholders) into BydbQL to 
eliminate QL injection.
 - Add reusable BydbQL binding: Prepare a query once, then Bind it many times 
without re-parsing or mutating the template. The liaison caches prepared 
statements on the gRPC query path (LRU bounded by entry count and bytes, on by 
default; `--bydbql-prepared-cache-size`/`--bydbql-prepared-cache-max-bytes`, 
`bydbql_prepared_cache_*` metrics) so repeated templates skip parsing. To 
pinpoint un-cacheable and slow queries without high-cardinality labels, the 
query access log tags each entry by  [...]
diff --git a/docs/design/post-trace-pipeline.md 
b/docs/design/post-trace-pipeline.md
index 7b134ef3d..604b5bb13 100644
--- a/docs/design/post-trace-pipeline.md
+++ b/docs/design/post-trace-pipeline.md
@@ -124,7 +124,7 @@ The contract is designed around three hard requirements.
 - The plugin re-exports an `ABIVersion` constant; the engine refuses to load 
on mismatch with its own compiled `sdk.ABIVersion`, turning a silent miscompile 
into a clear, fail-fast error. Configuration is a structured 
`google.protobuf.Struct` (`SamplerPlugin.config`) set directly in the pipeline 
config; the engine serializes it to canonical JSON and the plugin unmarshals 
those `[]byte` into its own typed config — so the wire form is structured and 
inspectable while the `.so` boundary sta [...]
 - **Distribution:** operators build plugins against the released, 
version-tagged `pkg/pipeline/sdk` using the **same CI image / Go version / 
`-trimpath` / CGO flags** as the data node. (Background constraints, 
well-documented but outside this design's verified scope: Go plugins are 
**Linux/macOS only**, **cannot be unloaded** — so changing a plugin requires a 
node restart, there is no hot-reload — and a plugin **panic crashes the host** 
unless contained; see fail-open below.)
 
-**(3) Projection / column selection — spans optional, more than tags.** The 
plugin declares the columns it needs up front via `Project()`, which returns a 
`Projection{ Tags []string; SpanIDs bool; Spans bool }`. The engine turns 
`Tags` into the **same `model.TagProjection`** the block reader already honors 
(`blockMetadata.tagProjection`), so only those tag columns are decoded into 
`block.tags` — literally the query engine's tag-projection path 
(`trace/v1/query.proto`), not a new mechanis [...]
+**(3) Projection / column selection — spans optional, more than tags.** The 
plugin declares the columns it needs up front via `Project()`, which returns a 
`Projection{ Tags []string; SpanIDs bool; Spans bool }`. The engine turns 
`Tags` into the **same `model.TagProjection`** the block reader already honors 
(`blockMetadata.tagProjection`), so only those tag columns are decoded into 
`block.tags` — literally the query engine's tag-projection path 
(`trace/v1/query.proto`), not a new mechanis [...]
 
 **Go SDK (`pkg/pipeline/sdk`).** The batch types mirror the native trace 
`block`/`tag` (`banyand/trace/block.go`); the engine fills them with the 
block's own slices, shared **read-only** (not copied) — see the read-only 
contract below:
 
@@ -232,7 +232,7 @@ type Verdict struct {
 }
 ```
 
-These types are real and live in [`pkg/pipeline/sdk`](../../pkg/pipeline/sdk); 
the block above is the conceptual layout, and the canonical definitions plus 
the value-decode helpers (`TagColumn.At`, `DecodeTagValue`) are in that 
package. A complete reference plugin implementing the Scenario 6.1 sampler — 
config parsing, `Project()`, and tag/span extraction — lives at 
[`pkg/pipeline/sdk/_example/segment-tail-sampler`](../../pkg/pipeline/sdk/_example/segment-tail-sampler).
+These types are real and live in [`pkg/pipeline/sdk`](../../pkg/pipeline/sdk); 
the block above is the conceptual layout, and the canonical definitions plus 
the value-decode helpers (`TagColumn.At`, `DecodeTagValue`) are in that 
package. The shipped first-party samplers live at 
[`plugins/skywalking/sw-trace-sampler`](../../plugins/skywalking/sw-trace-sampler)
 and 
[`plugins/skywalking/zipkin-trace-sampler`](../../plugins/skywalking/zipkin-trace-sampler);
 a standalone teaching example of th [...]
 
 **Verdict shape — boolean keep-mask.** `Decide` returns a `[]bool` aligned to 
`batch.Traces`: `Keep[i]` retains trace `i`. This is the simplest 
fully-vectorized contract and makes the alignment invariant trivial to check 
(the engine rejects a verdict whose length ≠ `len(batch.Traces)`). The 
keep/drop is per `trace_id`, matching the merger's per-`trace_id` write 
granularity (§7.1).
 
@@ -370,7 +370,7 @@ Both showcase trace groups (`sw_trace`, `sw_zipkinTrace`) 
declare a Hot → Warm
 
 - The migration engine runs the source stage's retention `plugins` chain 
against every trace in the partition.
 
-- **No dynamic splitting is performed:** all retained data is written to the 
next stage's node group. Traces the source stage's plugin drops are omitted 
from the target write stream, reducing the physical size of the migrated 
partition. With Scenario 6.1's hot retention plugin (config `min_duration: 
100ms`) a healthy `/homepage` trace (2802 ms) is kept and migrates to Warm; a 
PostgreSQL-touching trace is kept by the config's tag rule and migrates too; a 
healthy fast trace (6 ms) matches  [...]
+- **No dynamic splitting is performed:** all retained data is written to the 
next stage's node group. Traces the source stage's plugin drops are omitted 
from the target write stream, reducing the physical size of the migrated 
partition. With Scenario 6.1's hot retention plugin (config 
`durationThresholdMs: 100`) a healthy `/homepage` trace (2802 ms) is kept and 
migrates to Warm; a PostgreSQL-touching trace is kept by the config's tag rule 
and migrates too; a healthy fast trace (6 ms) mat [...]
 
 - When the partition matures past the Warm 7-day ttl, the Warm stage's 
retention plugin runs at this Warm→Cold boundary — the gate that decides what 
enters Cold (typically the strictest config; in Scenario 6.1 it keeps errors 
only). Only the traces it keeps are written into the Cold parts and then 
retained for the full Cold TTL; everything else is dropped here. Cold itself 
does no further sampling — Cold compactions are lossless.
 
@@ -394,7 +394,7 @@ Below are two operational scenarios represented as complete 
`TracePipelineConfig
 
 ```Plain Text
 {
-  "metadata": { "group": "sw_trace", "name": "segment-tail-sampler" },
+  "metadata": { "group": "sw_trace", "name": "sw-trace-sampler" },
   "enabled": true,
   "stages": [
     {
@@ -403,14 +403,14 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
         {
           "name": "hot-retention",
           "sampler": {
-            "path": "segment-stage-retention.so",
+            "path": "sw-trace-sampler.so",
             "abi_version": 1,
             "config": {
-              "min_duration": "0.100s",
-              "keep_errors": true,
-              "keep_tag_rules": [
-                { "tag_key": "db.type",  "equals": "PostgreSQL" },
-                { "tag_key": "mq.queue", "equals": "queue-songs-ping" }
+              "durationThresholdMs": 100,
+              "keepErrors": true,
+              "keepTagRules": [
+                { "tagKey": "db.type",  "equals": "PostgreSQL" },
+                { "tagKey": "mq.queue", "equals": "queue-songs-ping" }
               ]
             }
           }
@@ -423,10 +423,10 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
         {
           "name": "warm-retention",
           "sampler": {
-            "path": "segment-stage-retention.so",
+            "path": "sw-trace-sampler.so",
             "abi_version": 1,
             "config": {
-              "keep_errors": true
+              "keepErrors": true
             }
           }
         }
@@ -436,17 +436,17 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
   "schema_names": ["segment"],
   "plugins": [
     {
-      "name": "segment-tail-sampler",
+      "name": "sw-trace-sampler",
       "sampler": {
-        "path": "segment-tail-sampler.so",
+        "path": "sw-trace-sampler.so",
         "abi_version": 1,
         "config": {
-          "duration_threshold": "0.500s",
-          "keep_errors": true,
-          "healthy_sample_rate": 0.1,
-          "keep_tag_rules": [
-            { "tag_key": "db.type",  "equals": "PostgreSQL" },
-            { "tag_key": "mq.queue", "equals": "queue-songs-ping" }
+          "durationThresholdMs": 500,
+          "keepErrors": true,
+          "healthySampleRate": 0.1,
+          "keepTagRules": [
+            { "tagKey": "db.type",  "equals": "PostgreSQL" },
+            { "tagKey": "mq.queue", "equals": "queue-songs-ping" }
           ]
         }
       }
@@ -458,27 +458,27 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
 }
 ```
 
-> Each plugin link's `config` is a structured `google.protobuf.Struct` set 
directly in the pipeline config (not an opaque blob); the engine does not 
interpret its keys — it serializes the object to JSON and hands the bytes to 
the plugin's constructor, which unmarshals them into its own typed config. The 
gating chain here is a single `sampler` link whose `Project()` returns 
`Projection{ Tags: ["is_error", "db.type", "mq.queue"], SpanIDs: false, Spans: 
false }`, so only those three tag col [...]
+> Each plugin link's `config` is a structured `google.protobuf.Struct` set 
directly in the pipeline config (not an opaque blob); the engine does not 
interpret its keys — it serializes the object to JSON and hands the bytes to 
the plugin's constructor, which unmarshals them into its own typed config. The 
gating chain here is a single `sampler` link whose `Project()` returns 
`Projection{ Tags: ["start_time", "latency", "is_error", "tags"], SpanIDs: 
false, Spans: false }`, so the verdict it [...]
 
 - **Retention Dynamics** (real `sw_trace` traces; gating runs in Hot, owned by 
the gating chain; per-stage retention owned by each stage's `plugins` chain):
 
-    - The error trace `5fcdb353-…` (`POST /test`, `agent::app`, `is_error=1`, 
4 ms) is a sure-keep at gating: the gating plugin keeps it because its config 
sets `keep_errors`. At Hot→Warm, the hot retention plugin keeps it (config 
`keep_errors`) → migrated. At Warm→Cold, the warm retention plugin 
(errors-only) keeps it → migrated into Cold, where it is retained for the full 
30-day Cold TTL (Cold does no further sampling).
+    - The error trace `5fcdb353-…` (`POST /test`, `agent::app`, `is_error=1`, 
4 ms) is a sure-keep at gating: the gating plugin keeps it because its config 
sets `keepErrors`. At Hot→Warm, the hot retention plugin keeps it (config 
`keepErrors`) → migrated. At Warm→Cold, the warm retention plugin (errors-only) 
keeps it → migrated into Cold, where it is retained for the full 30-day Cold 
TTL (Cold does no further sampling).
 
-    - The slow healthy trace `b03bb932-…` (`/homepage`, `agent::ui` → 
`agent::frontend`, 2802 ms) is a sure-keep at gating: the gating plugin keeps 
it because 2802 ms > 500 ms `duration_threshold` in its config (from `MaxTS - 
MinTS`, no span decode). At Hot→Warm: the hot plugin keeps it (2802 ms ≥ its 
100 ms `min_duration`) → kept through Warm. At Warm→Cold: the warm plugin 
(errors-only) drops it (no error) → **dropped at Warm→Cold; never enters Cold**.
+    - The slow healthy trace `b03bb932-…` (`/homepage`, `agent::ui` → 
`agent::frontend`, 2802 ms) is a sure-keep at gating: the gating plugin keeps 
it because its 2802 ms envelope > the 500 ms `durationThresholdMs` in its 
config (from the `start_time`/`latency` tags, no span decode). At Hot→Warm: the 
hot plugin keeps it (2802 ms ≥ its 100 ms `durationThresholdMs`) → kept through 
Warm. At Warm→Cold: the warm plugin (errors-only) drops it (no error) → 
**dropped at Warm→Cold; never enters Cold**.
 
     - A PostgreSQL-touching trace (e.g. `b31e4be8-…`, `agent::songs` 
`UndertowDispatch`, 3 ms, `db.type=PostgreSQL`) is sure-kept at gating via the 
gating plugin's `db.type` keep-tag-rule. At Hot→Warm: the hot plugin keeps it 
(its config's `db.type` rule) → kept through Warm. At Warm→Cold: the warm 
plugin (errors-only) drops it (no error) → **dropped at Warm→Cold; never enters 
Cold**.
 
-    - A healthy fast trace such as `GET:/songs` at 6 ms (`agent::songs`, 
`http.status_code=200`) is only kept at gating if the gating plugin's 
`healthy_sample_rate` (`0.1`) hash retains it (deterministic `hash(trace_id) < 
0.1`, since it matches no sure-keep rule). If kept, at Hot→Warm the hot plugin 
drops it (6 ms < 100 ms, no error, no tag match) → **dropped at Hot→Warm 
migration**.
+    - A healthy fast trace such as `GET:/songs` at 6 ms (`agent::songs`, 
`http.status_code=200`) is only kept at gating if the gating plugin's 
`healthySampleRate` (`0.1`) hash retains it (deterministic `hash(trace_id) < 
0.1`, since it matches no sure-keep rule). If kept, at Hot→Warm the hot plugin 
drops it (6 ms < 100 ms, no error, no tag match) → **dropped at Hot→Warm 
migration**.
 
 ### 6.2 Scenario 2: Istio / Zipkin Mesh Edge Sampling (`sw_zipkinTrace`)
 
-- **Objective**: On the showcase `sw_zipkinTrace` group (schema 
`zipkin_span`), apply a lower-cost edge sampler to the Istio service-mesh 
spans. The Zipkin schema has no first-class `is_error` column, so server errors 
are caught with a tag rule on the flattened `query` attributes rather than 
`keep_errors`; mesh gateway spans are kept by tag. Targets the group's Warm and 
Cold stages. As in §6.1 the gating verdict is owned by a native Go plugin chain 
(a single sampler link, §2.5); per-stag [...]
+- **Objective**: On the showcase `sw_zipkinTrace` group (schema 
`zipkin_span`), apply a lower-cost edge sampler to the Istio service-mesh 
spans. The Zipkin schema has no first-class `is_error` column: `keepErrors` 
detects Zipkin's conventional `error` span tag inside the flattened `query` 
attributes, and 5xx-only failures are caught with an explicit `query` tag rule. 
Targets the group's Warm and Cold stages. As in §6.1 the gating verdict is 
owned by a native Go plugin chain (a single sam [...]
 
 - **Configuration JSON**:
 
 ```Plain Text
 {
-  "metadata": { "group": "sw_zipkinTrace", "name": "zipkin-edge-sampler" },
+  "metadata": { "group": "sw_zipkinTrace", "name": "zipkin-trace-sampler" },
   "enabled": true,
   "stages": [
     {
@@ -487,13 +487,12 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
         {
           "name": "warm-retention",
           "sampler": {
-            "path": "zipkin-stage-retention.so",
+            "path": "zipkin-trace-sampler.so",
             "abi_version": 1,
             "config": {
-              "min_duration": "1s",
-              "keep_tag_rules": [
-                { "tag_key": "query", "regex": "http\\.status_code=5\\d\\d" },
-                { "tag_key": "local_endpoint_service_name", "equals": 
"gateway.sample-services" }
+              "durationThresholdMs": 1000,
+              "keepTagRules": [
+                { "tagKey": "query", "regex": "http\\.status_code=5\\d\\d" }
               ]
             }
           }
@@ -504,16 +503,16 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
   "schema_names": ["zipkin_span"],
   "plugins": [
     {
-      "name": "zipkin-edge-sampler",
+      "name": "zipkin-trace-sampler",
       "sampler": {
-        "path": "zipkin-edge-sampler.so",
+        "path": "zipkin-trace-sampler.so",
         "abi_version": 1,
         "config": {
-          "duration_threshold": "1.000s",
-          "keep_errors": false,
-          "healthy_sample_rate": 0.05,
-          "keep_tag_rules": [
-            { "tag_key": "query", "regex": "http\\.status_code=5\\d\\d" }
+          "durationThresholdMs": 1000,
+          "keepErrors": true,
+          "healthySampleRate": 0.05,
+          "keepTagRules": [
+            { "tagKey": "query", "regex": "http\\.status_code=5\\d\\d" }
           ]
         }
       }
@@ -523,13 +522,13 @@ Below are two operational scenarios represented as 
complete `TracePipelineConfig
 }
 ```
 
-> Each link's `config` is a structured `google.protobuf.Struct` set directly 
in the pipeline config (not an opaque blob); the engine does not interpret its 
keys — it serializes the object to JSON and hands the bytes to the plugin's 
constructor, which unmarshals them into its own typed config. The gating 
chain's single `sampler` link `Project()` returns `Projection{ Tags: ["query"], 
SpanIDs: false, Spans: false }`, so only the `query` tag column (plus the 
intrinsic `trace_id` / `MinTS` /  [...]
+> Each link's `config` is a structured `google.protobuf.Struct` set directly 
in the pipeline config (not an opaque blob); the engine does not interpret its 
keys — it serializes the object to JSON and hands the bytes to the plugin's 
constructor, which unmarshals them into its own typed config. The gating 
chain's single `sampler` link `Project()` returns `Projection{ Tags: 
["timestamp_millis", "duration", "query"], SpanIDs: false, Spans: false }`, so 
the verdict itself reads only those tag [...]
 
 - **Retention Dynamics** (real `sw_zipkinTrace` spans; gating runs in Hot, 
owned by the gating chain; per-stage retention owned by the Warm `plugins` 
chain):
 
-    - The slowest mesh call observed — `trace_id 0961e077…`, a 30.7 s 
`istio.skywalking-showcase` client span to Grafana's live-WS endpoint 
(`http.status_code=101`) — is a sure-keep at gating: the gating plugin keeps it 
because 30.7 s > 1 s `duration_threshold` in its config (from `MaxTS - MinTS`, 
no span decode). At Warm→Cold: the warm retention plugin keeps it (30.7 s ≥ its 
1 s `min_duration`) → migrated into Cold, where it is retained for the full 
Cold TTL (Cold does no further sampling).
+    - The slowest mesh call observed — `trace_id 0961e077…`, a 30.7 s 
`istio.skywalking-showcase` client span to Grafana's live-WS endpoint 
(`http.status_code=101`) — is a sure-keep at gating: the gating plugin keeps it 
because its 30.7 s envelope > the 1 s `durationThresholdMs` in its config (from 
the `timestamp_millis`/`duration` tags, no span decode). At Warm→Cold: the warm 
retention plugin keeps it (30.7 s ≥ its 1 s `durationThresholdMs`) → migrated 
into Cold, where it is retained fo [...]
 
-    - A gateway span on `gateway.sample-services` at the mesh p90 (~19 ms): 
the gating plugin passes it only via the `0.05` `healthy_sample_rate` hash (19 
ms < 1 s, no 5xx, so it matches no sure-keep rule). If kept, at Warm→Cold the 
warm plugin keeps it (19 ms < 1 s, but its `local_endpoint_service_name = 
gateway.sample-services` rule matches) → migrated into Cold, where it is 
retained for the full Cold TTL.
+    - A gateway span on `gateway.sample-services` at the mesh p90 (~19 ms): 
the gating plugin passes it only via the `0.05` `healthySampleRate` hash (19 ms 
< 1 s, no 5xx, so it matches no sure-keep rule). If kept, at Warm→Cold the warm 
plugin drops it (19 ms < 1 s and no 5xx, so it matches no sure-keep rule) → 
**dropped at Warm→Cold**.
 
     - A typical p50 mesh span (~2 ms) is kept at gating only via the gating 
plugin's `0.05` sample. At Warm→Cold the warm plugin drops it (2 ms < 1 s, not 
a gateway span, no 5xx) → **dropped at Warm→Cold**.
 
@@ -642,7 +641,7 @@ flowchart TD
     subgraph A["A. In-merge filter at HOT-PHASE LSM COMPACTION 
(PIPELINE_EVENT_MERGE enabled)"]
         A1["1. mergeBlocks streams blocks ordered by trace_id 
(banyand/trace/merger.go).<br/>Maturity check: traceMaxTs (= 
timestampsMetadata.max) older than now − merge_grace (30s)?"]
         A1 -->|"NO"| A2["Pass blocks through unchanged; defer the verdict"]
-        A1 -->|"YES (stopped growing)"| A3["Engine builds a projected 
TraceBatch; plugin Decide returns a keep-mask.<br/>The §6.1 
segment-tail-sampler keeps this trace because is_error is set (config keeps 
errors);<br/>a healthy trace below the 0.500s threshold with no matching tag 
would fall to the 0.1 sample.<br/>Verdict for 5fcdb353-…: KEEP (drops here 
reclaim space during routine compaction)"]
+        A1 -->|"YES (stopped growing)"| A3["Engine builds a projected 
TraceBatch; plugin Decide returns a keep-mask.<br/>The §6.1 sw-trace-sampler 
keeps this trace because is_error is set (config keeps errors);<br/>a healthy 
trace below the 500ms threshold with no matching tag would fall to the 0.1 
sample.<br/>Verdict for 5fcdb353-…: KEEP (drops here reclaim space during 
routine compaction)"]
     end
     subgraph B["B. Plugin gating pass at HOT FINALIZATION 
(PIPELINE_EVENT_FINALIZE enabled, once per settled Hot segment)"]
         B2["2. Post-trace scheduler tick observes a segment with watermark 
past segment.End + finalize_grace (300s), not yet finalized"]
@@ -650,8 +649,8 @@ flowchart TD
         B3 --> B4["4. For each surviving trace_id (not already dropped at A), 
evaluate the plugin gating policy — same as step 1.<br/>Surviving traces 
continue into the stage lifecycle; failures are dropped from the segment."]
     end
     subgraph C["C. Per-stage retention at MIGRATION-OUT (once per stage 
boundary, when a StageRule has a plugin)"]
-        C5["5. Source stage migrates to the next stage (e.g. Hot → Warm after 
the 1-day TTL). Pre-migration rewrite reads each source part with blockReader 
and runs the source stage's retention plugin, returning a keep-mask per 
trace_id:<br/>Hot → Warm (hot StageRule.plugins, config min_duration 0.100s): 
duration ≥ 0.100s? (no, only 4ms); keep_errors AND is_error? YES → Keep, 
migrate<br/>Warm → Cold (warm StageRule.plugins, config keep_errors only): 
keep_errors AND is_error? YES → Keep,  [...]
-        C5 --> C6["6. RETENTION OUTCOME: the error trace survives the gating 
plugin at A and B, then the stage retention plugin keeps it (keep_errors) at 
every migration boundary in C.<br/>Retained Hot → Warm → Cold; written into the 
Cold (type=cold) parts. Cold stage TTL = 30 days (per the group's 
ResourceOpts.stages)."]
+        C5["5. Source stage migrates to the next stage (e.g. Hot → Warm after 
the 1-day TTL). Pre-migration rewrite reads each source part with blockReader 
and runs the source stage's retention plugin, returning a keep-mask per 
trace_id:<br/>Hot → Warm (hot StageRule.plugins, config durationThresholdMs 
100): duration ≥ 0.100s? (no, only 4ms); keepErrors AND is_error? YES → Keep, 
migrate<br/>Warm → Cold (warm StageRule.plugins, config keepErrors only): 
keepErrors AND is_error? YES → Keep, [...]
+        C5 --> C6["6. RETENTION OUTCOME: the error trace survives the gating 
plugin at A and B, then the stage retention plugin keeps it (keepErrors) at 
every migration boundary in C.<br/>Retained Hot → Warm → Cold; written into the 
Cold (type=cold) parts. Cold stage TTL = 30 days (per the group's 
ResourceOpts.stages)."]
     end
     A3 --> B2
     B4 --> C5
diff --git a/plugins/README.md b/plugins/README.md
index 8ba0483a2..2ed393cf4 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -10,9 +10,25 @@ the `banyand` server image (see `docs/operation/plugins.md`).
 plugins/
   README.md                             # this file
   skywalking/
+    internal/tracesampler/              # shared §6.1/§6.2 sampler engine (not 
a plugin)
     latencystatussampler/main.go        # seed: package main; NewSampler; 
ABIVersion
+    sw-trace-sampler/main.go            # segment schema (sw_trace): 
duration/is_error/tags rules
+    zipkin-trace-sampler/main.go        # zipkin_span schema (sw_zipkinTrace): 
duration/query rules
 ```
 
+`internal/tracesampler` is a shared library, not a plugin: it has no `main.go`,
+so `make build-plugins` (which only builds `plugins/*/*/` dirs containing a
+`main.go`) skips it, and the `sw-trace-sampler` / `zipkin-trace-sampler` mains
+each compile it into their own `.so`. The two mains differ only in a `Schema`
+value naming the columns each trace schema keeps the rule inputs in — see
+[First-party sampler config](#first-party-sampler-config) for that mapping and
+the keys both plugins accept.
+
+Because every `keepTagRules` entry resolves to the flattened array, a rule 
naming
+a first-class column could never match; the engine rejects the ones a `Schema`
+names (the error, duration, and start-time columns) at construction instead of
+letting the rule silently never fire.
+
 The `skywalking/` namespace holds plugins maintained by the SkyWalking
 project itself. A future third-party vendor would get its own sibling
 namespace (e.g. `plugins/acme/`) — but see the "Third-party plugins" section
@@ -50,6 +66,64 @@ func main() {}                               // required by 
-buildmode=plugin
   vectorized `sdk.TraceBatch`/`sdk.TraceBlock` types are the full contract —
   see the package doc on `pkg/pipeline/sdk` for the authoritative reference.
 
+## First-party sampler config
+
+`sw-trace-sampler` and `zipkin-trace-sampler` share one engine
+(`internal/tracesampler`) and accept the same keys. This is the behavioral
+reference; for `bydb.yml` wiring, the `SW_STORAGE_BANYANDB_*` environment
+overrides, and tuning guidance, see SkyWalking's
+[BanyanDB storage 
guide](https://skywalking.apache.org/docs/main/next/en/setup/backend/storages/banyandb/).
+
+| Key | Type | Meaning |
+|---|---|---|
+| `durationThresholdMs` | int | Keep a trace whose end-to-end envelope reaches 
this many **milliseconds**. `0` disables. |
+| `keepErrors` | bool | Keep a trace carrying an error. Rejected on a schema 
that expresses error neither as a column nor as an array key. |
+| `errorTag` | string | Override the tag `keepErrors` reads. Ignored unless 
`keepErrors` is set. |
+| `keepTagRules` | list \| string | Sure-keep rules over the searchable tags: 
`{tagKey, exists\|equals\|in\|regex}`, or the compact 
`key=value,key=~regex,key` string form. |
+| `healthySampleRate` | float | Fraction in `[0,1]` of the remaining traces to 
keep, by FNV-1a hash of the trace ID. Accepts a quoted number, since 
SkyWalking's config loader stringifies float placeholders. |
+
+**Unknown keys are rejected.** Every option is a keep rule, so a key that
+silently missed would leave a sampler with no rules that drops the whole group 
—
+the failure mode is data loss, not a stale setting. A wholly empty config 
(`{}`,
+which `pipeline_loader` also substitutes for an absent `SamplerPlugin.config`) 
is
+rejected for the same reason. Note Go matches field names case-insensitively, 
so
+this catches wrong words, not wrong capitalization; the reference
+`_example/segment-tail-sampler` uses `snake_case`, so a config copied from it 
is
+refused outright.
+
+**Per-schema inputs.** The two mains differ only in this mapping:
+
+| 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` (ms) | `duration` (µs) |
+
+Both start columns are BanyanDB timestamp columns, so both arrive in
+**nanoseconds** whatever the tag is called; only the duration unit differs, and
+`Schema.DurationTagNanosPerUnit` normalizes it. `durationThresholdMs` is
+milliseconds on both.
+
+**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
+```
+
+so a trace slow only through sequential spans is caught. It is deliberately not
+the intrinsic `MaxTS − MinTS`, which is the spread of per-row *start* 
timestamps
+(and `0` for a single-row trace).
+
+**Order and fail-open.** Rules are OR-ed in the order duration, errors, tag
+rules, healthy sample; the first match wins, so order affects cost only. A
+rule that cannot be evaluated **keeps** the trace: absent duration columns
+mean "can't tell", not "not slow", since the columns are schema-declared and
+their absence implies the block was written under a different schema. An
+absent tag array is *not* treated that way — a trace with no searchable tags
+is ordinary data, so tag rules simply do not match.
+
 ## ABI / toolchain lock (read this before building for production)
 
 A Go plugin is loaded via `plugin.Open`, which requires the `.so` and the
diff --git a/plugins/skywalking/internal/tracesampler/sampler.go 
b/plugins/skywalking/internal/tracesampler/sampler.go
new file mode 100644
index 000000000..f9476414c
--- /dev/null
+++ b/plugins/skywalking/internal/tracesampler/sampler.go
@@ -0,0 +1,763 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package tracesampler is the shared post-trace sampler engine behind the
+// first-party sw-trace-sampler and zipkin-trace-sampler plugins. Both plugins
+// implement the same keep logic from docs/design/post-trace-pipeline.md
+// (Scenario 6.1 for SkyWalking segments, 6.2 for Zipkin) — a duration
+// threshold, sure-keep error and tag rules, and a deterministic healthy sample
+// — and differ only in how each schema physically stores the columns those
+// rules read. That per-schema knowledge is a Schema value passed to New;
+// everything else lives here so the two plugins stay a few lines each and
+// cannot drift apart.
+//
+// Tag matching accounts for the real BanyanDB trace layout SkyWalking writes:
+// searchable tags are not first-class columns but "key=value" entries 
flattened
+// into one string-array column ("tags" for segments, "query" for Zipkin), so
+// every keepTagRules entry is matched against that array.
+package tracesampler
+
+import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "hash/fnv"
+       "regexp"
+       "sort"
+       "strconv"
+       "strings"
+
+       "github.com/apache/skywalking-banyandb/pkg/pb/v1/valuetype"
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+)
+
+// Schema captures the per-plugin storage facts the shared engine needs: where 
a
+// trace's searchable tags live, which columns carry the duration envelope, and
+// how (or whether) "error" is expressed. The two first-party plugins differ 
only
+// in the Schema they pass to New.
+//
+// Only the columns named below are ever read as columns. Every keepTagRules 
entry
+// resolves to ArrayTagColumn, so a rule can only match a searchable tag — 
never a
+// first-class column such as local_endpoint_service_name. New rejects a tagKey
+// naming one of the columns it does know to be first-class, rather than 
letting
+// the rule silently never fire.
+type Schema struct {
+       // ArrayTagColumn is the flattened searchable-tag column: a string 
array of
+       // "key=value" entries — "tags" for the SkyWalking segment schema, 
"query"
+       // for the Zipkin schema. Every keepTagRules entry resolves here: a 
rule whose
+       // tagKey is this column's own name matches raw entries, and any other 
tagKey
+       // matches the value of the "tagKey=" entries.
+       ArrayTagColumn string
+       // ErrorTag is what keepErrors reads. An empty ErrorTag means the 
schema has no
+       // error signal at all and keepErrors is rejected at construction.
+       ErrorTag string
+       // DurationTag and StartTimeTag drive the durationThresholdMs rule, 
which
+       // keeps a trace whose end-to-end envelope reaches the threshold. The 
envelope
+       // is max(start + duration) - min(start) over the trace's rows, 
computed from
+       // these two per-row tags. This is the true trace duration (it catches 
traces
+       // that are slow only through sequential segments), not the spread of 
the
+       // intrinsic MinTS/MaxTS (which is per-row start timestamps and 0 for a
+       // single-row trace).
+       //
+       // DurationTag is the per-row duration column: "latency" (segment 
duration, ms)
+       // for the segment schema, "duration" (span duration, µs) for Zipkin.
+       DurationTag string
+       // StartTimeTag is the per-row start timestamp column: "start_time" for 
the
+       // segment schema, "timestamp_millis" for Zipkin. Both are stored as 
timestamp
+       // tags (unix nanoseconds), so the plugin reads them as int64 ns.
+       StartTimeTag string
+       // DurationTagNanosPerUnit converts one DurationTag unit to nanoseconds 
so the
+       // envelope math is ns-consistent with StartTimeTag: 1_000_000 for a 
millisecond
+       // tag (segment latency), 1_000 for a microsecond tag (Zipkin duration).
+       DurationTagNanosPerUnit int64
+       // ErrorTagInArray says the error signal is a KEY INSIDE ArrayTagColumn 
rather
+       // than a column of its own. The segment schema has a real is_error 
column
+       // (false); Zipkin has none, but OAP flattens every span tag into 
"query" as both
+       // a bare key and "key=value", so a span carrying Zipkin's conventional 
"error"
+       // tag is detectable there (true).
+       //
+       // Note this is a tag CONVENTION, not an authoritative field: 
instrumentations
+       // that signal failure only through http.status_code 5xx or 
otel.status_code are
+       // not covered, and need an explicit keepTagRules entry.
+       ErrorTagInArray bool
+}
+
+// firstClassColumn reports the config option covering tagKey when the schema
+// stores it as a real column rather than an entry of ArrayTagColumn, or "" 
when
+// a rule on tagKey is legitimate.
+//
+// errorColumn is the error column read at RUNTIME — schema.ErrorTag or the
+// operator's errorTag override — and "" when keepErrors is off or the signal 
is an
+// array entry. It is a separate parameter because checking schema.ErrorTag 
alone
+// would miss an override: keepErrors would read the override as a column 
while a
+// rule on the same key matched array entries, the exact silent no-op this 
guard
+// exists to prevent.
+//
+// Only the columns a Schema names can be checked; a rule on some other 
first-class
+// column (local_endpoint_service_name, say) is still a silent no-op, since the
+// engine has no column inventory. Callers reject an empty tagKey before 
calling
+// this, which also stops an unset Schema field from aliasing every rule.
+func (s Schema) firstClassColumn(tagKey, errorColumn string) string {
+       switch tagKey {
+       case s.DurationTag, s.StartTimeTag:
+               return "durationThresholdMs"
+       case errorColumn:
+               return "keepErrors"
+       }
+       // The schema's own error column stays first-class even when keepErrors 
is off or
+       // overridden — a rule still cannot reach it.
+       if tagKey == s.ErrorTag && !s.ErrorTagInArray {
+               return "keepErrors"
+       }
+       return ""
+}
+
+// rule is one sure-keep tag predicate. Exactly one matcher is honored, checked
+// in the order exists, equals, in, regex.
+type rule struct {
+       re     *regexp.Regexp
+       Regex  string   `json:"regex"`
+       TagKey string   `json:"tagKey"`
+       Equals string   `json:"equals"`
+       In     []string `json:"in"`
+       Exists bool     `json:"exists"`
+}
+
+// rules is a keepTagRules list that accepts either the explicit array form or 
a
+// single compact string. The compact form exists because the array-of-objects
+// form is unwieldy in an environment variable and, written inline in bydb.yml,
+// has to be quoted (its ": " would otherwise start a nested mapping):
+//
+//     keepTagRules: ${...:http.method=GET,http.status_code=~5\d\d}
+//
+// Grammar — rules separated by commas, each one of:
+//
+//     key=value    equals   (split on the FIRST "=", so values may contain 
"=")
+//     key=~regex   regex
+//     key          exists
+//
+// Commas inside (), [] or {} do not separate rules, so a quantifier such as
+// 5\d{2,3} survives. A value containing a top-level comma needs the array 
form.
+type rules []rule
+
+// UnmarshalJSON accepts the array form verbatim, or a string in the compact 
form.
+func (r *rules) UnmarshalJSON(data []byte) error {
+       trimmed := strings.TrimSpace(string(data))
+       if trimmed == "null" {
+               return nil
+       }
+       if !strings.HasPrefix(trimmed, `"`) {
+               // Array form: decode through a plain alias so this method is 
not re-entered.
+               // Strict for the same reason as the top-level config — a 
misspelled matcher key
+               // would otherwise leave a rule that silently matches nothing.
+               var explicit []rule
+               dec := json.NewDecoder(bytes.NewReader(data))
+               dec.DisallowUnknownFields()
+               if err := dec.Decode(&explicit); err != nil {
+                       return err
+               }
+               *r = explicit
+               return nil
+       }
+       var compact string
+       if err := json.Unmarshal(data, &compact); err != nil {
+               return err
+       }
+       parsed, err := parseCompactRules(compact)
+       if err != nil {
+               return err
+       }
+       *r = parsed
+       return nil
+}
+
+// parseCompactRules parses the compact "key=value,key=~regex,key" grammar.
+func parseCompactRules(s string) (rules, error) {
+       var out rules
+       for _, part := range splitTopLevel(s, ',') {
+               part = strings.TrimSpace(part)
+               if part == "" {
+                       continue
+               }
+               eq := strings.Index(part, "=")
+               if eq == 0 {
+                       return nil, fmt.Errorf("rule %q has an empty tagKey", 
part)
+               }
+               if eq < 0 {
+                       out = append(out, rule{TagKey: part, Exists: true})
+                       continue
+               }
+               key, value := part[:eq], part[eq+1:]
+               // Report an empty operand here, where the operator's actual 
mistake is still
+               // visible. Left to the generic validation below it would 
surface as the
+               // misleading "has no matcher", since an empty Equals/Regex is 
indistinguishable
+               // from an unset one.
+               if strings.HasPrefix(value, "~") {
+                       if value == "~" {
+                               return nil, fmt.Errorf("rule %q has an empty 
regex after %q", part, "=~")
+                       }
+                       out = append(out, rule{TagKey: key, Regex: value[1:]})
+                       continue
+               }
+               if value == "" {
+                       return nil, fmt.Errorf("rule %q has an empty value; 
write %q to keep on the tag's "+
+                               "presence regardless of value", part, key)
+               }
+               out = append(out, rule{TagKey: key, Equals: value})
+       }
+       return out, nil
+}
+
+// splitTopLevel splits on sep, ignoring separators nested in (), [] or {} — 
so a
+// regex quantifier like {2,3} is not mistaken for a rule boundary.
+func splitTopLevel(s string, sep rune) []string {
+       var parts []string
+       depth, start := 0, 0
+       for i, c := range s {
+               switch c {
+               case '(', '[', '{':
+                       depth++
+               case ')', ']', '}':
+                       if depth > 0 {
+                               depth--
+                       }
+               case sep:
+                       if depth == 0 {
+                               parts = append(parts, s[start:i])
+                               start = i + len(string(sep))
+                       }
+               }
+       }
+       return append(parts, s[start:])
+}
+
+// flexFloat is a float64 that also accepts a JSON string ("0.1"). SkyWalking's
+// bydb.yml resolves a ${ENV:default} placeholder through a converter that 
keeps
+// only String/Integer/Long/Boolean types, so a float written as a placeholder
+// arrives as a quoted string in the config Struct. Accepting both keeps float
+// options env-overridable instead of failing the whole config at admission.
+type flexFloat float64
+
+// UnmarshalJSON accepts a JSON number or a numeric JSON string. A JSON null 
is a
+// no-op leaving the zero value, per the encoding/json convention — unlike the
+// default decoder, a custom Unmarshaler is handed null rather than skipped, 
and a
+// blank value in bydb.yml reaches the plugin as null.
+func (f *flexFloat) UnmarshalJSON(data []byte) error {
+       if string(data) == "null" {
+               return nil
+       }
+       v, err := strconv.ParseFloat(strings.Trim(string(data), `"`), 64)
+       if err != nil {
+               return fmt.Errorf("expected a number, got %s", string(data))
+       }
+       *f = flexFloat(v)
+       return nil
+}
+
+// config is the JSON shape the operator sets in SamplerPlugin.config.
+// Field order here (and in the other structs in this file) is chosen to 
satisfy
+// govet's fieldalignment: pointer-bearing fields first, the slice last among 
them,
+// then plain scalars.
+type config struct {
+       ErrorTag     string `json:"errorTag"`
+       KeepTagRules rules  `json:"keepTagRules"`
+       // DurationThresholdMs keeps a trace whose end-to-end duration (the 
envelope of
+       // its rows' start+duration, see Schema.DurationTag/StartTimeTag) 
reaches this
+       // many milliseconds. Milliseconds match SkyWalking's own latency unit. 
0 (or
+       // omitted) disables it.
+       DurationThresholdMs int64     `json:"durationThresholdMs"`
+       HealthySampleRate   flexFloat `json:"healthySampleRate"`
+       KeepErrors          bool      `json:"keepErrors"`
+}
+
+// Sampler keeps a trace when any sure-keep rule matches, and otherwise admits 
a
+// deterministic fraction of the healthy remainder. It implements sdk.Sampler.
+type Sampler struct {
+       arrayColumn             string
+       errorTag                string
+       durationTag             string
+       startTimeTag            string
+       rules                   []rule
+       requiredTags            []string
+       errorRule               rule
+       durationThresholdMs     int64
+       durationTagNanosPerUnit int64
+       healthySampleRate       float64
+       keepErrors              bool
+       errorTagInArray         bool
+}
+
+// New parses and validates the operator config against the given Schema,
+// compiles any regex matchers, and computes the projection. A returned error
+// rejects the plugin at admission.
+func New(configJSON []byte, schema Schema) (sdk.Sampler, error) {
+       var c config
+       if len(configJSON) > 0 {
+               // Strict: an unrecognized key is an error, not a silent no-op. 
Ignoring one is
+               // catastrophic here rather than merely untidy — every option 
this plugin has is
+               // a KEEP rule, so a config whose keys all miss (a snake_case 
config copied from
+               // the _example plugin, say) yields a sampler with no rules at 
all, which drops
+               // every trace in the group. Note Go matches field names 
case-insensitively, so
+               // this catches wrong words, not wrong capitalization.
+               dec := json.NewDecoder(bytes.NewReader(configJSON))
+               dec.DisallowUnknownFields()
+               if err := dec.Decode(&c); err != nil {
+                       // Not necessarily malformed JSON: a compact 
keepTagRules string that fails
+                       // its own grammar also surfaces here, via 
rules.UnmarshalJSON.
+                       return nil, fmt.Errorf("tracesampler: invalid config: 
%w", err)
+               }
+       }
+       // A config carrying no keys at all leaves every option at its zero 
value, which for
+       // this plugin means no keep rule of any kind: it would drop the entire 
group. That
+       // state is reachable by OMISSION, not just by writing {} — 
pipeline_loader.go
+       // substitutes []byte("{}") when SamplerPlugin.config is unset — so a 
plugin
+       // registered without a config would silently delete the group's traces.
+       //
+       // This deliberately tests for an absent KEY, not for absent keep 
criteria: rate 0
+       // with no rules is a supported setting, so {"healthySampleRate": 0} 
stays valid.
+       var present map[string]json.RawMessage
+       if len(configJSON) > 0 {
+               // Already known well-formed: the strict decode above would 
have rejected it.
+               _ = json.Unmarshal(configJSON, &present)
+       }
+       if len(present) == 0 {
+               return nil, errors.New("tracesampler: config is empty, so no 
keep rule is set and every " +
+                       "trace in the group would be dropped; set at least one 
option (or leave the pipeline " +
+                       "disabled if retaining nothing is the intent)")
+       }
+       if c.HealthySampleRate < 0 || c.HealthySampleRate > 1 {
+               return nil, fmt.Errorf("tracesampler: healthySampleRate %v out 
of [0,1]", c.HealthySampleRate)
+       }
+       s := &Sampler{
+               arrayColumn:       schema.ArrayTagColumn,
+               rules:             c.KeepTagRules,
+               healthySampleRate: float64(c.HealthySampleRate),
+               keepErrors:        c.KeepErrors,
+       }
+       if c.KeepErrors {
+               s.errorTag = schema.ErrorTag
+               if c.ErrorTag != "" {
+                       s.errorTag = c.ErrorTag
+               }
+               if s.errorTag == "" {
+                       return nil, fmt.Errorf("tracesampler: keepErrors is set 
but this schema has no error signal; "+
+                               "catch errors with a keepTagRules entry on %q 
instead", schema.ArrayTagColumn)
+               }
+               s.errorTagInArray = schema.ErrorTagInArray
+               // The in-array error signal is just an exists rule on the 
error key, evaluated
+               // by the same matcher as keepTagRules so the two can never 
diverge.
+               s.errorRule = rule{TagKey: s.errorTag, Exists: true}
+       }
+       if c.DurationThresholdMs < 0 {
+               return nil, fmt.Errorf("tracesampler: durationThresholdMs must 
be >= 0, got %d", c.DurationThresholdMs)
+       }
+       s.durationThresholdMs = c.DurationThresholdMs
+       if s.durationThresholdMs > 0 {
+               if schema.DurationTag == "" || schema.StartTimeTag == "" || 
schema.DurationTagNanosPerUnit <= 0 {
+                       return nil, fmt.Errorf("tracesampler: 
durationThresholdMs is set but this schema has no duration/start-time tag 
configured")
+               }
+               s.durationTag = schema.DurationTag
+               s.startTimeTag = schema.StartTimeTag
+               s.durationTagNanosPerUnit = schema.DurationTagNanosPerUnit
+       }
+
+       // Build the projection: the error column (when keepErrors is set), the 
duration
+       // envelope columns, and — for any rule at all — the flattened array 
column, the
+       // only place a rule can match. Compile regex matchers once, here, not 
per batch.
+       tagSet := make(map[string]struct{})
+       // errorColumn is set only when the error signal is read as a real 
column, so it
+       // carries the errorTag override that schema.ErrorTag would not.
+       errorColumn := ""
+       if s.keepErrors {
+               if s.errorTagInArray {
+                       tagSet[s.arrayColumn] = struct{}{}
+               } else {
+                       tagSet[s.errorTag] = struct{}{}
+                       errorColumn = s.errorTag
+               }
+       }
+       if s.durationTag != "" {
+               tagSet[s.durationTag] = struct{}{}
+               tagSet[s.startTimeTag] = struct{}{}
+       }
+       if err := validateRules(s.rules, schema, errorColumn); err != nil {
+               return nil, err
+       }
+       if len(s.rules) > 0 {
+               // Every rule resolves to the flattened array column, whatever 
its tagKey.
+               tagSet[s.arrayColumn] = struct{}{}
+       }
+       s.requiredTags = make([]string, 0, len(tagSet))
+       for k := range tagSet {
+               s.requiredTags = append(s.requiredTags, k)
+       }
+       // Stable order keeps Project() reproducible across runs (Go map 
iteration is
+       // randomized); the engine treats Tags as a set, but logs, tests, and 
caches
+       // benefit from determinism.
+       sort.Strings(s.requiredTags)
+       return s, nil
+}
+
+// validateRules rejects any rule that could never match and compiles each 
regex in
+// place, so the cost is paid once at admission rather than per batch. 
errorColumn is
+// the error column read at runtime; see Schema.firstClassColumn.
+func validateRules(rs []rule, schema Schema, errorColumn string) error {
+       for i := range rs {
+               r := &rs[i]
+               if r.TagKey == "" {
+                       return fmt.Errorf("tracesampler: keepTagRules[%d] has 
empty tagKey", i)
+               }
+               if !r.Exists && r.Equals == "" && len(r.In) == 0 && r.Regex == 
"" {
+                       return fmt.Errorf("tracesampler: keepTagRules[%d] 
(tagKey %q) has no matcher; "+
+                               "set one of exists/equals/in/regex", i, 
r.TagKey)
+               }
+               if firstClass := schema.firstClassColumn(r.TagKey, 
errorColumn); firstClass != "" {
+                       // A hard error, not a warning: plugins have no log 
channel, and silently
+                       // accepting the rule is the failure mode this check 
exists to remove. The
+                       // array-column escape hatch matters when a searchable 
tag legitimately shares
+                       // a name with a column (a Zipkin span tag literally 
called "duration").
+                       return fmt.Errorf("tracesampler: keepTagRules[%d] 
targets %q, which this schema stores as a "+
+                               "first-class column, not as an entry of %q; 
such a rule could never match. Use the %s option "+
+                               "instead, or — to match a searchable tag that 
happens to share the name — write the rule "+
+                               "against %q itself, e.g. {tagKey: %q, regex: 
\"^%s=\"}",
+                               i, r.TagKey, schema.ArrayTagColumn, firstClass,
+                               schema.ArrayTagColumn, schema.ArrayTagColumn, 
r.TagKey)
+               }
+               if r.Regex != "" {
+                       re, err := regexp.Compile(r.Regex)
+                       if err != nil {
+                               return fmt.Errorf("tracesampler: 
keepTagRules[%d] bad regex %q: %w", i, r.Regex, err)
+                       }
+                       r.re = re
+               }
+       }
+       return nil
+}
+
+// Kind reports the sampler kind, satisfying the generic sdk.Plugin interface
+// that sdk.Sampler embeds.
+func (s *Sampler) Kind() sdk.Kind { return sdk.KindSampler }
+
+// Project declares the columns the verdict reads: the duration and start-time
+// columns (when a duration threshold is set), the error column (when 
keepErrors
+// is set), the flattened array column (when any tag rule is configured, or the
+// error signal lives there), plus the span-id column only when a span-count 
rule
+// is configured.
+//
+// Spans stays false, so the verdict never reads span bodies. That is a bound 
on
+// what this plugin inspects, NOT on what the merge decodes: the engine sets
+// forceSlow whenever a projection names any tag (merger.go, finalizer.go), 
which
+// disables the raw-copy fast path, and the resulting decode reads the whole 
block
+// — span bodies and every tag, not just the projected ones. Enabling a sampler
+// therefore costs a full block decode per merge; keeping the projection small
+// bounds only this plugin's own work.
+func (s *Sampler) Project() sdk.Projection {
+       return sdk.Projection{Tags: s.requiredTags}
+}
+
+// Close releases resources; this sampler holds none.
+func (s *Sampler) Close() error { return nil }
+
+// Decide returns a keep-mask aligned to batch.Traces. The batch is read-only.
+// It never returns an error: a per-row decode failure fails open for that 
trace
+// (kept), so one malformed value can never make the sampler drop data.
+func (s *Sampler) Decide(batch *sdk.TraceBatch) (sdk.Verdict, error) {
+       keep := make([]bool, len(batch.Traces))
+       for i := range batch.Traces {
+               keep[i] = s.keepTrace(&batch.Traces[i])
+       }
+       return sdk.Verdict{Keep: keep}, nil
+}
+
+// keepTrace applies the sure-keep rules, then the deterministic healthy 
sample.
+// Any decode error encountered while evaluating a sure-keep predicate keeps 
the
+// trace (fail open), never drops it.
+func (s *Sampler) keepTrace(b *sdk.TraceBlock) bool {
+       // Duration keep: the trace's end-to-end envelope reaches the threshold.
+       if s.durationThresholdMs > 0 {
+               hit, err := s.hasSlowTrace(b)
+               if err != nil || hit {
+                       return true
+               }
+       }
+       // Decode the flattened tag array ONCE. DecodeTagValue rewrites its 
source slice
+       // in place for string arrays (vararray.UnmarshalVarArray shifts bytes 
left past
+       // every escape), so decoding the same column again — once per rule, as 
an earlier
+       // version did — reads corrupted entries whenever a value contains "|" 
or "\\".
+       var entries []string
+       if s.needsArrayColumn() {
+               decoded, err := arrayEntries(b.Tag(s.arrayColumn))
+               if err != nil {
+                       return true // fail open
+               }
+               entries = decoded
+       }
+       // Error keep.
+       if s.keepErrors {
+               if s.errorTagInArray {
+                       if matchEntries(entries, &s.errorRule, s.arrayColumn) {
+                               return true
+                       }
+               } else {
+                       hit, err := s.hasErrorColumn(b)
+                       if err != nil || hit {
+                               return true
+                       }
+               }
+       }
+       // Sure-keep tag rules, all evaluated against the already-decoded 
entries.
+       for i := range s.rules {
+               if matchEntries(entries, &s.rules[i], s.arrayColumn) {
+                       return true
+               }
+       }
+       // Healthy remainder: deterministic hash(trace_id) < rate, stable across
+       // re-evaluation at merge and finalization.
+       if s.healthySampleRate > 0 && sampleFraction(b.TraceID) < 
s.healthySampleRate {
+               return true
+       }
+       return false
+}
+
+// nanosPerMillis converts the millisecond threshold to nanoseconds for the
+// envelope comparison, matching the nanosecond start-time tags.
+const nanosPerMillis = int64(1_000_000)
+
+// errNoDurationEnvelope reports that no row yielded a usable start/duration 
pair,
+// so the envelope could not be computed at all. keepTrace treats any error as 
a
+// keep, which is the intended reading: "can't tell", not "not slow". The 
columns
+// are declared by the Schema, so their absence means the block was written 
under a
+// different schema — typically the wrong plugin for the group — and answering
+// "not slow" there would silently drop every trace the operator asked to keep.
+//
+// This is deliberately the opposite of an absent ArrayTagColumn, which stays
+// fail-closed: a trace carrying no searchable tags is ordinary data, and 
keeping
+// it would make every tagless trace survive and the tag rules pointless.
+var errNoDurationEnvelope = errors.New("no row carries both a start-time and a 
duration value")
+
+// hasSlowTrace reports whether the trace's end-to-end envelope reaches the
+// threshold. The envelope is max(start + duration) - min(start) over the rows,
+// where start comes from StartTimeTag (a timestamp tag, unix ns) and duration
+// from DurationTag (scaled to ns by DurationTagNanosPerUnit). This is the true
+// trace duration — it catches traces slow only through sequential segments —
+// and reads two cheap tag columns, never the span bodies.
+func (s *Sampler) hasSlowTrace(b *sdk.TraceBlock) (bool, error) {
+       startCol := b.Tag(s.startTimeTag)
+       durCol := b.Tag(s.durationTag)
+       if startCol == nil || durCol == nil {
+               return false, errNoDurationEnvelope
+       }
+       rows := len(durCol.Values)
+       if len(startCol.Values) < rows {
+               rows = len(startCol.Values)
+       }
+       var minStart, maxEnd int64
+       seen := false
+       for row := 0; row < rows; row++ {
+               sv, sErr := startCol.At(row)
+               if sErr != nil {
+                       return false, sErr
+               }
+               dv, dErr := durCol.At(row)
+               if dErr != nil {
+                       return false, dErr
+               }
+               if sv.IsNull() || dv.IsNull() {
+                       continue
+               }
+               // StartTimeTag must be a timestamp column, which BanyanDB 
stores as unix ns
+               // (write_standalone.go: GetTimestamp().AsTime().UnixNano()) — 
that is what makes
+               // reading it as ns correct. A plain Int64 start time is 
deliberately NOT accepted:
+               // it carries no unit, and unlike DurationTag there is no 
nanos-per-unit to scale
+               // it by, so treating it as ns would silently mis-measure a 
millisecond column.
+               // Skipping the row instead makes it a can't-tell, which fails 
open.
+               // Duration is an int in the tag's own unit, scaled below.
+               if sv.ValueType() != valuetype.ValueTypeTimestamp || 
dv.ValueType() != valuetype.ValueTypeInt64 {
+                       continue
+               }
+               start := sv.Int64()
+               end := start + dv.Int64()*s.durationTagNanosPerUnit
+               if !seen {
+                       minStart, maxEnd, seen = start, end, true
+                       continue
+               }
+               if start < minStart {
+                       minStart = start
+               }
+               if end > maxEnd {
+                       maxEnd = end
+               }
+       }
+       if !seen {
+               return false, errNoDurationEnvelope
+       }
+       return maxEnd-minStart >= s.durationThresholdMs*nanosPerMillis, nil
+}
+
+// needsArrayColumn reports whether any predicate reads the flattened tag 
array.
+func (s *Sampler) needsArrayColumn() bool {
+       return len(s.rules) > 0 || (s.keepErrors && s.errorTagInArray)
+}
+
+// arrayEntries decodes every entry of the flattened tag array, flattened 
across
+// rows. All tag predicates are existential over rows and entries, so 
collapsing
+// the rows loses nothing — and decoding once is what keeps the in-place string
+// array decode from corrupting later reads (see keepTrace).
+func arrayEntries(col *sdk.TagColumn) ([]string, error) {
+       if col == nil {
+               return nil, nil
+       }
+       var (
+               out     []string
+               scratch = sdk.TagColumn{Name: col.Name, ValueType: 
col.ValueType, Values: make([][]byte, 1)}
+               buf     []byte
+       )
+       for row := range col.Values {
+               if col.Values[row] == nil {
+                       continue
+               }
+               // Decode a COPY of the row, never the engine's buffer. The 
SDK's string-array
+               // decode rewrites its source in place 
(vararray.UnmarshalVarArray shifts bytes
+               // left past every escape), TraceBlock slices are documented 
read-only, and the
+               // engine hands the SAME TraceBatch to every link of a chain 
(sdk.applyChainLink).
+               // Decoding in place would therefore corrupt the value for 
every later link, making
+               // a rule's verdict depend on its position in the chain. Only 
values containing "|"
+               // or "\" carry an escape, so the damage is silent and 
data-dependent.
+               //
+               // Reusing buf across rows is safe because the decoder builds 
each entry with a
+               // string(...) conversion, which copies rather than aliasing 
the buffer.
+               buf = append(buf[:0], col.Values[row]...)
+               scratch.Values[0] = buf
+               v, err := scratch.At(0)
+               if err != nil {
+                       return nil, err
+               }
+               if v.IsNull() {
+                       continue
+               }
+               out = append(out, entriesOf(v)...)
+       }
+       return out, nil
+}
+
+// hasErrorColumn reports whether a dedicated error column is truthy on any 
row.
+func (s *Sampler) hasErrorColumn(b *sdk.TraceBlock) (bool, error) {
+       col := b.Tag(s.errorTag)
+       if col == nil {
+               return false, nil
+       }
+       for row := range col.Values {
+               v, err := col.At(row)
+               if err != nil {
+                       return false, err
+               }
+               if v.IsNull() {
+                       continue
+               }
+               switch v.ValueType() {
+               case valuetype.ValueTypeInt64:
+                       if v.Int64() != 0 {
+                               return true, nil
+                       }
+               case valuetype.ValueTypeStr:
+                       if str := v.Str(); str == "true" || str == "1" {
+                               return true, nil
+                       }
+               default:
+                       // No other type carries an error signal this schema 
understands.
+               }
+       }
+       return false, nil
+}
+
+// matchEntries reports whether the rule matches any decoded entry. A tagKey 
naming
+// the array column itself matches raw entries; any other tagKey matches the 
value
+// part of its "tagKey=" entries.
+//
+// An exists rule additionally accepts the BARE key, because the array holds a 
tag
+// as both "key" and "key=value" (and Zipkin annotations appear as raw values 
with
+// no "="). The comparison is exact, so a longer key such as "error_rate=0" 
never
+// satisfies an exists rule on "error". keepErrors routes through here too, so 
the
+// two cannot drift apart.
+func matchEntries(entries []string, r *rule, arrayColumn string) bool {
+       prefix := ""
+       if r.TagKey != arrayColumn {
+               prefix = r.TagKey + "="
+       }
+       for _, entry := range entries {
+               if r.Exists && entry == r.TagKey {
+                       return true
+               }
+               candidate := entry
+               if prefix != "" {
+                       if !strings.HasPrefix(entry, prefix) {
+                               continue
+                       }
+                       candidate = entry[len(prefix):]
+               }
+               if r.Exists || matchValue(r, candidate) {
+                       return true
+               }
+       }
+       return false
+}
+
+// matchValue applies the rule's active string matcher (equals, then in, then
+// regex) to a candidate. The exists matcher is handled by the caller, which
+// knows whether the value or entry is present.
+func matchValue(r *rule, candidate string) bool {
+       switch {
+       case r.Equals != "":
+               return candidate == r.Equals
+       case len(r.In) > 0:
+               for _, want := range r.In {
+                       if candidate == want {
+                               return true
+                       }
+               }
+               return false
+       case r.re != nil:
+               return r.re.MatchString(candidate)
+       default:
+               return false
+       }
+}
+
+// entriesOf returns the string entries of a value: the array elements for a
+// string array, or the single string for a plain string tag. Other types have
+// no string entries.
+func entriesOf(v sdk.Value) []string {
+       switch v.ValueType() {
+       case valuetype.ValueTypeStrArr:
+               return v.StrArr()
+       case valuetype.ValueTypeStr:
+               return []string{v.Str()}
+       default:
+               return nil
+       }
+}
+
+// sampleFraction maps a trace_id to a stable fraction in [0,1) via FNV-1a, so
+// the keep decision is deterministic and reproducible across passes. The top 
53
+// bits fill a float64 mantissa exactly (the technique math/rand uses), so the
+// result is strictly below 1 and a healthySampleRate of 1.0 keeps every trace.
+func sampleFraction(traceID string) float64 {
+       h := fnv.New64a()
+       _, _ = h.Write([]byte(traceID))
+       return float64(h.Sum64()>>11) / (1 << 53)
+}
diff --git a/plugins/skywalking/internal/tracesampler/sampler_test.go 
b/plugins/skywalking/internal/tracesampler/sampler_test.go
new file mode 100644
index 000000000..7f081f15f
--- /dev/null
+++ b/plugins/skywalking/internal/tracesampler/sampler_test.go
@@ -0,0 +1,631 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// White-box tests for the shared sampler engine, verified offline against the
+// sdktest fixture kit (no .so build, no cluster) per plugins/README.md.
+package tracesampler
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/skywalking-banyandb/pkg/pb/v1/valuetype"
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk/sdktest"
+)
+
+// segmentSchema and zipkinSchema mirror the two first-party plugins' Schema
+// values so the engine is exercised exactly as they configure it.
+var (
+       segmentSchema = Schema{
+               ArrayTagColumn: "tags", ErrorTag: "is_error",
+               DurationTag: "latency", StartTimeTag: "start_time", 
DurationTagNanosPerUnit: 1_000_000,
+       }
+       zipkinSchema = Schema{
+               ArrayTagColumn: "query", ErrorTag: "error", ErrorTagInArray: 
true,
+               DurationTag: "duration", StartTimeTag: "timestamp_millis", 
DurationTagNanosPerUnit: 1_000,
+       }
+       // noErrorSchema has no error signal at all, so keepErrors must be 
rejected.
+       noErrorSchema = Schema{ArrayTagColumn: "tags"}
+)
+
+func TestNew_ConfigValidation(t *testing.T) {
+       cases := []struct {
+               name   string
+               config string
+               schema Schema
+       }{
+               {"invalid JSON", `{`, segmentSchema},
+               {"rate below 0", `{"healthySampleRate":-0.1}`, segmentSchema},
+               {"rate above 1", `{"healthySampleRate":1.1}`, segmentSchema},
+               {"empty tagKey", `{"keepTagRules":[{"equals":"x"}]}`, 
segmentSchema},
+               {"no matcher", `{"keepTagRules":[{"tagKey":"db.type"}]}`, 
segmentSchema},
+               {"bad regex", 
`{"keepTagRules":[{"tagKey":"db.type","regex":"("}]}`, segmentSchema},
+               {"bad duration type", `{"durationThresholdMs":"nope"}`, 
segmentSchema},
+               {"negative duration", `{"durationThresholdMs":-5}`, 
segmentSchema},
+               {"keepErrors without error signal", `{"keepErrors":true}`, 
noErrorSchema},
+               // A rule on a first-class column can never match, since every 
rule resolves to
+               // the flattened array column. Rejecting beats silently never 
firing.
+               {"rule on is_error column", 
`{"keepTagRules":[{"tagKey":"is_error","equals":"true"}]}`, segmentSchema},
+               {"rule on latency column", 
`{"keepTagRules":[{"tagKey":"latency","exists":true}]}`, segmentSchema},
+               {"rule on start_time column", 
`{"keepTagRules":[{"tagKey":"start_time","exists":true}]}`, segmentSchema},
+               {"rule on zipkin duration column", 
`{"keepTagRules":[{"tagKey":"duration","exists":true}]}`, zipkinSchema},
+               // The errorTag override is read as a COLUMN, so a rule on the 
same key is the same
+               // silent no-op. Checking schema.ErrorTag alone would have 
accepted this.
+               {
+                       "rule on overridden error column",
+                       
`{"keepErrors":true,"errorTag":"custom","keepTagRules":[{"tagKey":"custom","exists":true}]}`,
+                       segmentSchema,
+               },
+               // is_error stays a column even when keepErrors is off or 
points elsewhere.
+               {"rule on is_error with keepErrors off", 
`{"keepTagRules":[{"tagKey":"is_error","exists":true}]}`, segmentSchema},
+               {
+                       "rule on is_error when errorTag overridden",
+                       
`{"keepErrors":true,"errorTag":"custom","keepTagRules":[{"tagKey":"is_error","exists":true}]}`,
+                       segmentSchema,
+               },
+       }
+       for _, c := range cases {
+               t.Run(c.name, func(t *testing.T) {
+                       _, err := New([]byte(c.config), c.schema)
+                       require.Error(t, err)
+               })
+       }
+}
+
+func TestNew_Projection(t *testing.T) {
+       // Segment: keepErrors projects is_error; both flattened-tag rules 
collapse
+       // onto the single "tags" array column.
+       s, err := New([]byte(`{
+               "keepErrors": true,
+               "keepTagRules": [
+                       {"tagKey":"db.type","equals":"PostgreSQL"},
+                       {"tagKey":"mq.queue","equals":"q"}
+               ]
+       }`), segmentSchema)
+       require.NoError(t, err)
+       assert.ElementsMatch(t, []string{"is_error", "tags"}, s.Project().Tags)
+       assert.False(t, s.Project().SpanIDs)
+
+       // Zipkin declares no first-class columns, so every rule — whether it 
names the
+       // array column itself or a logical key inside it — collapses onto 
"query".
+       z, err := New([]byte(`{
+               "keepTagRules": [
+                       {"tagKey":"query","regex":"http\\.status_code=5\\d\\d"},
+                       {"tagKey":"http.method","equals":"GET"}
+               ]
+       }`), zipkinSchema)
+       require.NoError(t, err)
+       assert.ElementsMatch(t, []string{"query"}, z.Project().Tags)
+
+       // A duration threshold opts into the schema's duration + start-time 
columns.
+       d, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+       require.NoError(t, err)
+       assert.Contains(t, d.Project().Tags, "latency")
+       assert.Contains(t, d.Project().Tags, "start_time")
+}
+
+// TestNew_CompactKeepTagRules covers the compact string grammar, which is 
what an
+// operator realistically types into an environment variable.
+func TestNew_CompactKeepTagRules(t *testing.T) {
+       s, err := New([]byte(`{"keepTagRules":"db.type=PostgreSQL, 
http.status_code=~5\\d{2,3}, mq.queue"}`), segmentSchema)
+       require.NoError(t, err)
+       parsed := s.(*Sampler).rules
+       require.Len(t, parsed, 3, "the comma inside {2,3} must NOT split the 
regex rule")
+
+       assert.Equal(t, "db.type", parsed[0].TagKey)
+       assert.Equal(t, "PostgreSQL", parsed[0].Equals)
+
+       assert.Equal(t, "http.status_code", parsed[1].TagKey)
+       assert.Equal(t, `5\d{2,3}`, parsed[1].Regex)
+
+       assert.Equal(t, "mq.queue", parsed[2].TagKey)
+       assert.True(t, parsed[2].Exists, "a bare key is an exists rule")
+
+       // A value may contain "=" — only the first one separates key from 
value.
+       u, err := New([]byte(`{"keepTagRules":"url=http://x?a=b"}`), 
segmentSchema)
+       require.NoError(t, err)
+       assert.Equal(t, "http://x?a=b";, u.(*Sampler).rules[0].Equals)
+
+       // The array form still works, and an empty string yields no rules.
+       a, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"db.type","in":["A","B"]}]}`), 
segmentSchema)
+       require.NoError(t, err)
+       assert.Equal(t, []string{"A", "B"}, a.(*Sampler).rules[0].In)
+       e, err := New([]byte(`{"keepTagRules":""}`), segmentSchema)
+       require.NoError(t, err)
+       assert.Empty(t, e.(*Sampler).rules)
+
+       // Malformed rules are rejected at admission rather than silently 
ignored, and the
+       // message must name the actual mistake — an empty operand used to 
surface as the
+       // misleading "has no matcher", which describes a rule the operator did 
not write.
+       for _, tc := range []struct {
+               name, config, wantMsg string
+       }{
+               {"empty tagKey", `{"keepTagRules":"=novalue"}`, "empty tagKey"},
+               {"empty value", `{"keepTagRules":"db.type="}`, "empty value"},
+               {"empty regex", `{"keepTagRules":"db.type=~"}`, "empty regex"},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       _, cErr := New([]byte(tc.config), segmentSchema)
+                       require.Error(t, cErr)
+                       assert.Contains(t, cErr.Error(), tc.wantMsg)
+                       assert.NotContains(t, cErr.Error(), "has no matcher",
+                               "the generic matcher error hides which operand 
was empty")
+               })
+       }
+
+       // The empty-value error points at the exists form, so the suggested 
fix must work.
+       fix, err := New([]byte(`{"keepTagRules":"db.type"}`), segmentSchema)
+       require.NoError(t, err)
+       assert.True(t, fix.(*Sampler).rules[0].Exists)
+}
+
+// TestDecide_CompactRulesMatch proves the compact form produces the same 
verdicts
+// as the array form it desugars to.
+func TestDecide_CompactRulesMatch(t *testing.T) {
+       s, err := 
New([]byte(`{"keepTagRules":"db.type=PostgreSQL,http.status_code=~5\\d{2,3}"}`),
 segmentSchema)
+       require.NoError(t, err)
+
+       pg, e := sdktest.NewTrace("pg").Tag("tags", 
[]string{"db.type=PostgreSQL"}).Build()
+       require.NoError(t, e)
+       fiveXX, e := sdktest.NewTrace("5xx").Tag("tags", 
[]string{"http.status_code=503"}).Build()
+       require.NoError(t, e)
+       miss, e := sdktest.NewTrace("miss").Tag("tags", 
[]string{"db.type=MySQL", "http.status_code=200"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(pg, fiveXX, miss))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, false}, verdict.Keep)
+}
+
+// TestDecide_EscapedEntriesSurviveMultipleRules is a regression test for 
in-place
+// decoding. vararray.UnmarshalVarArray rewrites its source slice, shifting 
bytes left
+// past every escape, so a column decoded a SECOND time yields corrupted 
entries. The
+// engine used to decode the flattened array once per rule, which silently 
broke every
+// rule after the first whenever a tag value contained "|" or "\" — a 
retention filter
+// dropping data it was told to keep. Values here need escaping and the rule 
that must
+// match is deliberately LAST.
+func TestDecide_EscapedEntriesSurviveMultipleRules(t *testing.T) {
+       s, err := New([]byte(`{
+               "keepTagRules": [
+                       {"tagKey":"never.matches","equals":"nothing"},
+                       {"tagKey":"db.type","equals":"MySQL"},
+                       {"tagKey":"db.statement","equals":"SELECT a|b"}
+               ]
+       }`), segmentSchema)
+       require.NoError(t, err)
+
+       // "SELECT a|b" contains the entity delimiter and "C:\\tmp" the escape 
character,
+       // so both are stored escaped and are rewritten on decode.
+       hit, e := sdktest.NewTrace("escaped").
+               Tag("tags", []string{`db.type=PostgreSQL`, `url=C:\tmp`, 
`db.statement=SELECT a|b`}).Build()
+       require.NoError(t, e)
+       miss, e := sdktest.NewTrace("miss").
+               Tag("tags", []string{`db.type=PostgreSQL`, `db.statement=SELECT 
c|d`}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(hit, miss))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, false}, verdict.Keep,
+               "the third rule must still see intact entries after the first 
two were evaluated")
+}
+
+// TestDecide_ExistsMatchesBareKey pins the exists semantics after unifying it 
with
+// keepErrors: the key counts whether the array holds it as a bare entry or as
+// "key=value". The comparison is exact, so a longer key must NOT satisfy it —
+// that guard is what stops "error_rate=0" from looking like an error.
+func TestDecide_ExistsMatchesBareKey(t *testing.T) {
+       s, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"error","exists":true}]}`), zipkinSchema)
+       require.NoError(t, err)
+
+       bare, e := sdktest.NewTrace("bare").Tag("query", []string{"error", 
"http.method=GET"}).Build()
+       require.NoError(t, e)
+       withValue, e := sdktest.NewTrace("with-value").Tag("query", 
[]string{"error=boom"}).Build()
+       require.NoError(t, e)
+       emptyValue, e := sdktest.NewTrace("empty-value").Tag("query", 
[]string{"error="}).Build()
+       require.NoError(t, e)
+       lookalike, e := sdktest.NewTrace("lookalike").
+               Tag("query", []string{"error_rate=0", "errors=3", 
"http.method=GET"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(bare, withValue, 
emptyValue, lookalike))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, true, false}, verdict.Keep,
+               "exists must accept the bare key and key=value, but never a 
longer key")
+}
+
+// TestDecide_KeepErrorsEqualsExistsRule is the point of the unification: on an
+// in-array schema, keepErrors and an explicit exists rule on the same key must
+// produce identical verdicts, since the docs present them as equivalent.
+func TestDecide_KeepErrorsEqualsExistsRule(t *testing.T) {
+       viaFlag, err := 
New([]byte(`{"keepErrors":true,"healthySampleRate":0}`), zipkinSchema)
+       require.NoError(t, err)
+       viaRule, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"error","exists":true}],"healthySampleRate":0}`),
 zipkinSchema)
+       require.NoError(t, err)
+
+       for _, tc := range []struct {
+               name  string
+               query []string
+       }{
+               {"bare", []string{"error"}},
+               {"with-value", []string{"error=boom"}},
+               {"empty-value", []string{"error="}},
+               {"both-forms", []string{"error", "error=boom"}},
+               {"lookalike", []string{"error_rate=0"}},
+               {"healthy", []string{"http.status_code=200"}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       block, e := sdktest.NewTrace(tc.name).Tag("query", 
tc.query).Build()
+                       require.NoError(t, e)
+                       flagVerdict, _ := sdktest.Run(viaFlag, 
sdktest.Batch(block))
+                       ruleVerdict, _ := sdktest.Run(viaRule, 
sdktest.Batch(block))
+                       assert.Equal(t, flagVerdict.Keep, ruleVerdict.Keep,
+                               "keepErrors and the equivalent exists rule must 
agree")
+               })
+       }
+}
+
+func TestDecide_FlattenedTagMatchers(t *testing.T) {
+       s, err := New([]byte(`{
+               "keepTagRules": [
+                       {"tagKey":"db.type","equals":"PostgreSQL"},
+                       {"tagKey":"http.method","in":["GET","POST"]},
+                       {"tagKey":"mq.queue","exists":true}
+               ]
+       }`), segmentSchema)
+       require.NoError(t, err)
+
+       equalsHit, e := sdktest.NewTrace("equals").Tag("tags", 
[]string{"db.type=PostgreSQL"}).Build()
+       require.NoError(t, e)
+       inHit, e := sdktest.NewTrace("in").Tag("tags", 
[]string{"http.method=POST"}).Build()
+       require.NoError(t, e)
+       existsHit, e := sdktest.NewTrace("exists").Tag("tags", 
[]string{"mq.queue=whatever"}).Build()
+       require.NoError(t, e)
+       miss, e := sdktest.NewTrace("miss").Tag("tags", 
[]string{"db.type=MySQL", "http.method=DELETE"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(equalsHit, inHit, 
existsHit, miss))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, true, false}, verdict.Keep)
+}
+
+// TestDecide_RawArrayVsPrefixedMatch covers the two ways a rule resolves 
against
+// the flattened array: a tagKey naming the array itself ("query") matches 
whole
+// raw entries, while any other tagKey matches only the value part of its
+// "tagKey=" entries — so a rule on "http.status_code" must not be satisfied by
+// some other key that happens to contain the same text.
+func TestDecide_RawArrayVsPrefixedMatch(t *testing.T) {
+       z, err := New([]byte(`{
+               "keepTagRules": [
+                       {"tagKey":"query","regex":"http\\.status_code=5\\d\\d"},
+                       {"tagKey":"http.method","equals":"POST"}
+               ]
+       }`), zipkinSchema)
+       require.NoError(t, err)
+
+       // Raw-entry regex hit on the array column's own name.
+       fiveXX, e := sdktest.NewTrace("5xx").
+               Tag("query", []string{"http.status_code", 
"http.status_code=503"}).Build()
+       require.NoError(t, e)
+       // Prefixed hit: the "http.method=" entry's value equals POST.
+       post, e := sdktest.NewTrace("post").
+               Tag("query", []string{"http.method", 
"http.method=POST"}).Build()
+       require.NoError(t, e)
+       // Near miss: another key's VALUE is "POST", but the http.method rule 
is prefix
+       // scoped, so it must not match.
+       otherKey, e := sdktest.NewTrace("other-key").
+               Tag("query", []string{"rpc.verb=POST", 
"http.status_code=200"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(z, sdktest.Batch(fiveXX, post, otherKey))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, false}, verdict.Keep)
+}
+
+// TestDecide_Duration exercises the end-to-end trace envelope: the decision is
+// max(start_time + latency) - min(start_time) over the trace's segments. 
Segment
+// start_time is a timestamp tag in unix ns; latency is ms (units-per-ns = 
1e6).
+// The sequential case is the one the old per-segment "max latency" rule 
missed.
+func TestDecide_Duration(t *testing.T) {
+       s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+       require.NoError(t, err)
+
+       const ms = int64(1_000_000) // ns per ms, for start_time timestamps
+
+       // Single segment, latency 600ms ≥ 500ms → kept.
+       single, e := sdktest.NewTrace("slow-single").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               Tag("latency", int64(600)).Build()
+       require.NoError(t, e)
+
+       // Two sequential segments, each 300ms, staggered by 300ms: envelope
+       // = (300ms + 300ms) - 0 = 600ms ≥ 500ms → kept. Max single latency is 
300ms,
+       // so the old per-segment rule would have WRONGLY dropped this slow 
trace.
+       sequential, e := sdktest.NewTrace("slow-sequential").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               TagAs("start_time", valuetype.ValueTypeTimestamp, 300*ms).
+               Tag("latency", int64(300)).
+               Tag("latency", int64(300)).Build()
+       require.NoError(t, e)
+
+       // Two segments, envelope = (100ms + 100ms) - 0 = 200ms < 500ms → 
dropped.
+       fast, e := sdktest.NewTrace("fast").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               TagAs("start_time", valuetype.ValueTypeTimestamp, 100*ms).
+               Tag("latency", int64(100)).
+               Tag("latency", int64(100)).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(single, sequential, 
fast))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, false}, verdict.Keep)
+}
+
+// TestDecide_DurationZipkin: the Zipkin duration tag is microseconds
+// (nanos-per-unit = 1000) and timestamp_millis is a ns timestamp; the envelope
+// is computed in ns and compared to the ms threshold.
+func TestDecide_DurationZipkin(t *testing.T) {
+       z, err := New([]byte(`{"durationThresholdMs":1000}`), zipkinSchema)
+       require.NoError(t, err)
+
+       slow, e := sdktest.NewTrace("slow").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(1_500_000)).Build() // 1.5s (µs) ≥ 1s → 
kept
+       require.NoError(t, e)
+       fast, e := sdktest.NewTrace("fast").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(2_000)).Build() // 2ms → dropped
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(z, sdktest.Batch(slow, fast))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, false}, verdict.Keep)
+}
+
+// The Zipkin "error" signal is an array entry rather than a column, so a rule 
on it
+// stays legal — the first-class-column rejection must not over-reach.
+func TestNew_ZipkinErrorTagRuleAllowed(t *testing.T) {
+       _, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"error","exists":true}]}`), zipkinSchema)
+       require.NoError(t, err)
+}
+
+// A rule's verdict must not depend on the link's POSITION in a chain. The 
engine hands
+// the same *TraceBatch to every link, and the SDK's string-array decode 
rewrites its
+// source in place, so a link ahead that also reads the array column used to 
corrupt the
+// bytes this one decodes. Only values containing "|" or "\" carry an escape, 
so the
+// corruption is data-dependent — 
TestDecide_EscapedEntriesSurviveMultipleRules covers the
+// same hazard between rules inside one sampler; this covers it between links.
+func TestDecide_EscapedEntriesSurviveChainedLinks(t *testing.T) {
+       // Both links keep, so a false verdict can only come from corruption, 
not from the
+       // chain ANDing in an unrelated drop.
+       target := func() sdk.Sampler {
+               s, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"db.statement","equals":"SELECT 
a|b"}]}`), segmentSchema)
+               require.NoError(t, err)
+               return s
+       }
+       ahead := func() sdk.Sampler {
+               s, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"http.method","equals":"GET"}]}`), 
segmentSchema)
+               require.NoError(t, err)
+               return s
+       }
+       block := func() sdk.TraceBlock {
+               b, e := sdktest.NewTrace("escaped").
+                       Tag("tags", []string{`db.statement=SELECT a|b`, 
"http.method=GET"}).Build()
+               require.NoError(t, e)
+               return b
+       }
+
+       alone, report := sdktest.Run(target(), sdktest.Batch(block()))
+       require.NoError(t, report.Err)
+       require.Equal(t, []bool{true}, alone.Keep, "precondition: the rule 
matches when it runs alone")
+
+       behind, chainReport := sdktest.RunChain([]sdk.Sampler{ahead(), 
target()}, sdktest.Batch(block()))
+       assert.Empty(t, chainReport.Bypassed)
+       assert.Equal(t, []bool{true}, behind.Keep,
+               "a link ahead that decodes the same array column must not 
corrupt it for this one")
+
+       first, chainReport := sdktest.RunChain([]sdk.Sampler{target(), 
ahead()}, sdktest.Batch(block()))
+       assert.Empty(t, chainReport.Bypassed)
+       assert.Equal(t, []bool{true}, first.Keep, "and the verdict must not 
depend on chain position")
+}
+
+// An empty config sets no keep rule at all, so it would drop the whole group. 
It is
+// reachable by omission: pipeline_loader substitutes {} for an unset 
SamplerPlugin.config.
+func TestNew_RejectsEmptyConfig(t *testing.T) {
+       for _, cfg := range []string{`{}`, `   {}  `, ``} {
+               _, err := New([]byte(cfg), segmentSchema)
+               require.Error(t, err, "empty config %q must be rejected", cfg)
+       }
+       // A config with a key stays valid even when it sets no keep criteria — 
rate 0 with
+       // no rules is a supported setting, not a mistake.
+       _, err := New([]byte(`{"healthySampleRate":0}`), segmentSchema)
+       require.NoError(t, err)
+}
+
+// Every option this plugin has is a KEEP rule, so a key that silently misses 
yields a
+// sampler with no rules that drops the whole group. Unknown keys must 
therefore be a
+// decode error — most importantly for a snake_case config copied from the 
_example
+// plugin, which shares none of these spellings.
+func TestNew_RejectsUnknownConfigKeys(t *testing.T) {
+       for _, cfg := range []string{
+               `{"duration_threshold_ms":500,"keep_errors":true}`,
+               `{"keepErrorz":true}`,
+               `{"durationThresholdMs":500,"typo":1}`,
+               `{"keepTagRules":[{"tagKey":"db.type","equalz":"PostgreSQL"}]}`,
+       } {
+               _, err := New([]byte(cfg), segmentSchema)
+               require.Error(t, err, "config %s must be rejected, not silently 
ignored", cfg)
+       }
+
+       // Go matches field names case-insensitively, so this is accepted 
rather than
+       // rejected — the guard catches wrong words, not wrong capitalization.
+       _, err := New([]byte(`{"durationthresholdms":500}`), segmentSchema)
+       require.NoError(t, err)
+}
+
+// A Zipkin errorTag override still names an ARRAY entry, so a rule on it 
stays legal —
+// only an override read as a column is rejected.
+func TestNew_ZipkinErrorTagOverrideRuleAllowed(t *testing.T) {
+       _, err := 
New([]byte(`{"keepErrors":true,"errorTag":"otel.status_code",`+
+               
`"keepTagRules":[{"tagKey":"otel.status_code","exists":true}]}`), zipkinSchema)
+       require.NoError(t, err)
+}
+
+// The escape hatch the rejection message points at has to actually work: a 
searchable tag
+// whose key collides with a column name stays reachable via a rule on the 
array column.
+func TestDecide_ArrayColumnEscapeHatch(t *testing.T) {
+       z, err := 
New([]byte(`{"keepTagRules":[{"tagKey":"query","regex":"^duration=slow$"}]}`), 
zipkinSchema)
+       require.NoError(t, err)
+
+       hit, e := sdktest.NewTrace("collide").
+               Tag("query", []string{"http.method=GET", 
"duration=slow"}).Build()
+       require.NoError(t, e)
+       miss, e := sdktest.NewTrace("no-collide").
+               Tag("query", []string{"http.method=GET"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(z, sdktest.Batch(hit, miss))
+       require.NoError(t, report.Err)
+       assert.Equal(t, []bool{true, false}, verdict.Keep)
+}
+
+// A plain Int64 start time carries no unit, so it is skipped rather than 
assumed to be
+// nanoseconds. The trace then has no computable envelope and fails open — a 
4ms latency
+// under a 500ms threshold would otherwise have been dropped.
+func TestDecide_Int64StartTimeFailsOpen(t *testing.T) {
+       s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+       require.NoError(t, err)
+
+       tr, e := sdktest.NewTrace("int64-start").
+               Tag("start_time", int64(0)).
+               Tag("latency", int64(4)).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(tr))
+       require.NoError(t, report.Err)
+       assert.Equal(t, []bool{true}, verdict.Keep)
+}
+
+// A trace whose duration columns are absent cannot be evaluated at all, so the
+// threshold rule fails open. Answering "not slow" instead would make a plugin
+// pointed at the wrong group's schema silently drop every trace it was 
configured
+// to keep.
+func TestDecide_DurationMissingColumnsFailOpen(t *testing.T) {
+       s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+       require.NoError(t, err)
+
+       // Zipkin-shaped rows under the segment plugin: no start_time, no 
latency.
+       wrongSchema, e := sdktest.NewTrace("wrong-schema").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(1)).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(wrongSchema))
+       require.NoError(t, report.Err)
+       assert.Equal(t, []bool{true}, verdict.Keep)
+}
+
+func TestDecide_KeepErrors(t *testing.T) {
+       s, err := New([]byte(`{"keepErrors":true}`), segmentSchema)
+       require.NoError(t, err)
+
+       intErr, e := sdktest.NewTrace("int-error").Tag("is_error", 
int64(1)).Build()
+       require.NoError(t, e)
+       strErr, e := sdktest.NewTrace("str-error").Tag("is_error", 
"true").Build()
+       require.NoError(t, e)
+       ok, e := sdktest.NewTrace("ok").Tag("is_error", int64(0)).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(s, sdktest.Batch(intErr, strErr, ok))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs)
+       assert.Equal(t, []bool{true, true, false}, verdict.Keep)
+}
+
+// TestNew_HealthySampleRateAcceptsString covers the ${ENV:default} path: OAP's
+// config loader stringifies float placeholders, so the rate can arrive quoted.
+func TestNew_HealthySampleRateAcceptsString(t *testing.T) {
+       quoted, err := New([]byte(`{"healthySampleRate":"0"}`), segmentSchema)
+       require.NoError(t, err)
+       block, e := sdktest.NewTrace("x").Build()
+       require.NoError(t, e)
+       verdict, _ := sdktest.Run(quoted, sdktest.Batch(block))
+       assert.Equal(t, []bool{false}, verdict.Keep, `"0" must disable healthy 
sampling, not fail the config`)
+
+       // A quoted out-of-range rate is still range-checked; a non-numeric 
string is rejected.
+       _, err = New([]byte(`{"healthySampleRate":"1.5"}`), segmentSchema)
+       require.Error(t, err)
+       _, err = New([]byte(`{"healthySampleRate":"abc"}`), segmentSchema)
+       require.Error(t, err)
+
+       // A blank value in bydb.yml reaches the plugin as JSON null; treat it 
as "not set"
+       // (zero value) rather than failing the whole config.
+       nulled, err := New([]byte(`{"healthySampleRate":null}`), segmentSchema)
+       require.NoError(t, err)
+       verdict, _ = sdktest.Run(nulled, sdktest.Batch(block))
+       assert.Equal(t, []bool{false}, verdict.Keep, "null must behave as an 
unset rate, not fail the config")
+}
+
+func TestDecide_HealthySampleRate(t *testing.T) {
+       // Rate 1.0 keeps every trace (sampleFraction is strictly < 1).
+       all, err := New([]byte(`{"healthySampleRate":1.0}`), segmentSchema)
+       require.NoError(t, err)
+       for _, id := range []string{"a", "b", "c", "trace-42", ""} {
+               block, e := sdktest.NewTrace(id).Build()
+               require.NoError(t, e)
+               verdict, _ := sdktest.Run(all, sdktest.Batch(block))
+               assert.Equal(t, []bool{true}, verdict.Keep, "rate 1.0 must keep 
%q", id)
+       }
+
+       // Rate 0 disables healthy sampling: a trace matching no sure-keep rule 
drops.
+       none, err := New([]byte(`{"healthySampleRate":0}`), segmentSchema)
+       require.NoError(t, err)
+       block, e := sdktest.NewTrace("x").Build()
+       require.NoError(t, e)
+       verdict, _ := sdktest.Run(none, sdktest.Batch(block))
+       assert.Equal(t, []bool{false}, verdict.Keep)
+}
+
+// TestDecide_FailOpenOnDecodeError proves a malformed tag value keeps the 
trace
+// (fail open) rather than dropping it or erroring the whole batch: an is_error
+// column whose raw int64 is not 8 bytes fails to decode, and keepErrors keeps
+// the trace anyway.
+func TestDecide_FailOpenOnDecodeError(t *testing.T) {
+       s, err := New([]byte(`{"keepErrors":true}`), segmentSchema)
+       require.NoError(t, err)
+
+       batch := &sdk.TraceBatch{Traces: []sdk.TraceBlock{{
+               TraceID: "malformed",
+               Tags: []sdk.TagColumn{{
+                       Name:      "is_error",
+                       ValueType: valuetype.ValueTypeInt64,
+                       Values:    [][]byte{{0x01, 0x02, 0x03}}, // not 8 bytes 
→ decode error
+               }},
+       }}}
+       verdict, err := s.Decide(batch)
+       require.NoError(t, err)
+       assert.Equal(t, []bool{true}, verdict.Keep, "a decode error must fail 
open (keep), never drop")
+}
diff --git a/plugins/skywalking/sw-trace-sampler/main.go 
b/plugins/skywalking/sw-trace-sampler/main.go
new file mode 100644
index 000000000..a69b9e238
--- /dev/null
+++ b/plugins/skywalking/sw-trace-sampler/main.go
@@ -0,0 +1,71 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Command sw-trace-sampler is the first-party post-trace sampler for the
+// SkyWalking-native segment schema (group sw_trace, BanyanDB trace "segment").
+// It implements the Scenario 6.1 keep logic from
+// docs/design/post-trace-pipeline.md on the real segment columns:
+//
+//   - trace duration from the envelope of the segment "start_time"/"latency" 
tags (ms),
+//   - keepErrors from the first-class is_error tag,
+//   - keepTagRules matched against the flattened searchable-tag array "tags"
+//     (entries "key=value", e.g. "db.type=PostgreSQL"),
+//   - and a deterministic healthySampleRate hash of the trace id.
+//
+// Config JSON (from SamplerPlugin.config):
+//
+//     {
+//       "durationThresholdMs": 500,
+//       "keepErrors": true,
+//       "healthySampleRate": 0.1,
+//       "keepTagRules": [
+//         { "tagKey": "db.type",  "equals": "PostgreSQL" },
+//         { "tagKey": "mq.queue", "equals": "queue-songs-ping" }
+//       ]
+//     }
+//
+// Build it as a Go plugin with `make build-plugins`; it must use the same Go
+// toolchain and pinned pkg/pipeline/sdk as the data node (see 
plugins/README.md).
+package main
+
+import (
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+       
"github.com/apache/skywalking-banyandb/plugins/skywalking/internal/tracesampler"
+)
+
+// ABIVersion re-exports the SDK ABI version. The engine refuses to load the
+// plugin unless this equals its own compiled sdk.ABIVersion.
+var ABIVersion = sdk.ABIVersion
+
+// segmentSchema describes how the SkyWalking segment schema stores the columns
+// this sampler reads: trace duration is the envelope of the per-segment
+// "start_time" (unix-ns timestamp) and "latency" (ms) tags, errors are the
+// first-class is_error tag, and searchable tags are flattened into the "tags" 
array.
+var segmentSchema = tracesampler.Schema{
+       ArrayTagColumn:          "tags",
+       ErrorTag:                "is_error",
+       DurationTag:             "latency",
+       StartTimeTag:            "start_time",
+       DurationTagNanosPerUnit: 1_000_000,
+}
+
+// NewSampler is the constructor symbol the engine looks up.
+func NewSampler(configJSON []byte) (sdk.Sampler, error) {
+       return tracesampler.New(configJSON, segmentSchema)
+}
+
+func main() {}
diff --git a/plugins/skywalking/sw-trace-sampler/main_test.go 
b/plugins/skywalking/sw-trace-sampler/main_test.go
new file mode 100644
index 000000000..a77213b97
--- /dev/null
+++ b/plugins/skywalking/sw-trace-sampler/main_test.go
@@ -0,0 +1,93 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Offline verify-before-you-build-a-.so test for sw-trace-sampler, exercising
+// the Scenario 6.1 config against the sdktest fixture kit (no .so, no 
cluster).
+package main
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/skywalking-banyandb/pkg/pb/v1/valuetype"
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk/sdktest"
+)
+
+// config61 is the Scenario 6.1 gating config, with healthySampleRate pinned to
+// 0 so every keep here is attributable to a sure-keep rule (deterministic).
+const config61 = `{
+  "durationThresholdMs": 500,
+  "keepErrors": true,
+  "healthySampleRate": 0,
+  "keepTagRules": [
+    { "tagKey": "db.type",  "equals": "PostgreSQL" },
+    { "tagKey": "mq.queue", "equals": "queue-songs-ping" }
+  ]
+}`
+
+func TestSWTraceSampler_Scenario61(t *testing.T) {
+       sampler, err := NewSampler([]byte(config61))
+       require.NoError(t, err)
+
+       // Slow healthy trace: envelope 2802ms ≥ 500ms → sure-keep on duration.
+       slow, e := sdktest.NewTrace("slow-healthy").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               Tag("latency", int64(2802)).Build()
+       require.NoError(t, e)
+       // Every trace carries start_time: SegmentRecord stores it as a 
primitive column, so a
+       // real segment always has one, and a trace missing it is a can't-tell 
the duration rule
+       // deliberately fails open on. Omitting it here would keep these traces 
by that fallback
+       // and stop them exercising the rules they are named for.
+       //
+       // Fast error trace: sure-keep on keepErrors.
+       errTrace, e := sdktest.NewTrace("error").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               Tag("latency", int64(4)).Tag("is_error", int64(1)).Build()
+       require.NoError(t, e)
+       // Fast PostgreSQL trace: sure-keep on the db.type tag rule.
+       pg, e := sdktest.NewTrace("postgres").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               Tag("latency", int64(4)).
+               Tag("is_error", int64(0)).
+               Tag("tags", []string{"http.method=GET", 
"db.type=PostgreSQL"}).Build()
+       require.NoError(t, e)
+       // Fast healthy trace, matches nothing, rate 0 → dropped.
+       fast, e := sdktest.NewTrace("fast-healthy").
+               TagAs("start_time", valuetype.ValueTypeTimestamp, int64(0)).
+               Tag("latency", int64(6)).
+               Tag("is_error", int64(0)).
+               Tag("tags", []string{"http.method=GET"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(sampler, sdktest.Batch(slow, errTrace, 
pg, fast))
+       require.NoError(t, report.Err)
+       require.NoError(t, report.ProjectionErr)
+       assert.Empty(t, report.ProjectionDivergedIDs,
+               "sw-trace-sampler must only read the columns it projects 
(start_time, latency, is_error, tags)")
+       assert.Equal(t, []bool{true, true, true, false}, verdict.Keep)
+}
+
+func TestSWTraceSampler_Project(t *testing.T) {
+       sampler, err := NewSampler([]byte(config61))
+       require.NoError(t, err)
+       proj := sampler.Project()
+       assert.ElementsMatch(t, []string{"latency", "start_time", "is_error", 
"tags"}, proj.Tags)
+       assert.False(t, proj.SpanIDs)
+       assert.False(t, proj.Spans)
+}
diff --git a/plugins/skywalking/zipkin-trace-sampler/main.go 
b/plugins/skywalking/zipkin-trace-sampler/main.go
new file mode 100644
index 000000000..9a15a8bb4
--- /dev/null
+++ b/plugins/skywalking/zipkin-trace-sampler/main.go
@@ -0,0 +1,77 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Command zipkin-trace-sampler is the first-party post-trace sampler for the
+// Zipkin schema (group sw_zipkinTrace, BanyanDB trace "zipkin_span"). It
+// implements the Scenario 6.2 keep logic from
+// docs/design/post-trace-pipeline.md on the real Zipkin columns:
+//
+//   - trace duration from the envelope of the "timestamp_millis"/"duration" 
tags (duration is µs),
+//   - keepTagRules matched against the flattened "query" array, whose entries
+//     include both bare keys and "key=value" such as "http.status_code=500",
+//   - and a deterministic healthySampleRate hash of the trace id.
+//
+// The Zipkin schema has no is_error column. keepErrors instead detects 
Zipkin's
+// conventional "error" span tag, which OAP flattens into "query" as both a 
bare
+// key and "error=<message>". That is a tag convention, not an authoritative 
field:
+// instrumentations that only signal failure through http.status_code 5xx or
+// otel.status_code need an explicit rule, e.g. 'http.status_code=~5\d\d'.
+//
+// Config JSON (from SamplerPlugin.config):
+//
+//     {
+//       "durationThresholdMs": 1000,
+//       "keepErrors": true,
+//       "healthySampleRate": 0.05,
+//       "keepTagRules": [
+//         { "tagKey": "query", "regex": "http\\.status_code=5\\d\\d" }
+//       ]
+//     }
+//
+// Build it as a Go plugin with `make build-plugins`; it must use the same Go
+// toolchain and pinned pkg/pipeline/sdk as the data node (see 
plugins/README.md).
+package main
+
+import (
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+       
"github.com/apache/skywalking-banyandb/plugins/skywalking/internal/tracesampler"
+)
+
+// ABIVersion re-exports the SDK ABI version. The engine refuses to load the
+// plugin unless this equals its own compiled sdk.ABIVersion.
+var ABIVersion = sdk.ABIVersion
+
+// zipkinSchema describes how the Zipkin schema stores the columns this sampler
+// reads: trace duration is the envelope of the per-span "timestamp_millis"
+// (unix-ns timestamp) and "duration" (µs) tags, searchable tags are flattened
+// into the "query" array, and keepErrors looks for Zipkin's "error" tag inside
+// that same array (ErrorTagInArray) since the schema has no error column.
+var zipkinSchema = tracesampler.Schema{
+       ArrayTagColumn:          "query",
+       ErrorTag:                "error",
+       ErrorTagInArray:         true,
+       DurationTag:             "duration",
+       StartTimeTag:            "timestamp_millis",
+       DurationTagNanosPerUnit: 1_000,
+}
+
+// NewSampler is the constructor symbol the engine looks up.
+func NewSampler(configJSON []byte) (sdk.Sampler, error) {
+       return tracesampler.New(configJSON, zipkinSchema)
+}
+
+func main() {}
diff --git a/plugins/skywalking/zipkin-trace-sampler/main_test.go 
b/plugins/skywalking/zipkin-trace-sampler/main_test.go
new file mode 100644
index 000000000..3eee68bad
--- /dev/null
+++ b/plugins/skywalking/zipkin-trace-sampler/main_test.go
@@ -0,0 +1,118 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Offline verify-before-you-build-a-.so test for zipkin-trace-sampler,
+// exercising the Scenario 6.2 config against the sdktest fixture kit.
+package main
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/skywalking-banyandb/pkg/pb/v1/valuetype"
+       "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk/sdktest"
+)
+
+// config62 is the Scenario 6.2 gating config, with healthySampleRate pinned to
+// 0 so every keep here is attributable to a sure-keep rule (deterministic).
+const config62 = `{
+  "durationThresholdMs": 1000,
+  "healthySampleRate": 0,
+  "keepTagRules": [
+    { "tagKey": "query", "regex": "http\\.status_code=5\\d\\d" }
+  ]
+}`
+
+func TestZipkinTraceSampler_Scenario62(t *testing.T) {
+       sampler, err := NewSampler([]byte(config62))
+       require.NoError(t, err)
+
+       // Slow mesh call: envelope 30.7s (duration µs) ≥ 1s → sure-keep on 
duration.
+       slow, e := sdktest.NewTrace("slow-mesh").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(30_700_000)).
+               Tag("query", []string{"http.status_code", 
"http.status_code=101"}).Build()
+       require.NoError(t, e)
+       // Every trace carries timestamp_millis: ZipkinSpanRecord stores it as 
a primitive
+       // column, so a real span always has one, and a trace missing it is a 
can't-tell the
+       // duration rule deliberately fails open on. Omitting it here would 
keep these traces by
+       // that fallback and stop them exercising the rules they are named for.
+       //
+       // Fast 5xx span: sure-keep on the query regex rule.
+       serverError, e := sdktest.NewTrace("server-error").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(3_000)).
+               Tag("query", []string{"http.status_code", 
"http.status_code=500"}).Build()
+       require.NoError(t, e)
+       // Fast p50 span, matches nothing, rate 0 → dropped.
+       p50, e := sdktest.NewTrace("p50").
+               TagAs("timestamp_millis", valuetype.ValueTypeTimestamp, 
int64(0)).
+               Tag("duration", int64(2_000)).
+               Tag("query", []string{"http.status_code", 
"http.status_code=200"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(sampler, sdktest.Batch(slow, 
serverError, p50))
+       require.NoError(t, report.Err)
+       require.NoError(t, report.ProjectionErr)
+       assert.Empty(t, report.ProjectionDivergedIDs,
+               "zipkin-trace-sampler must only read the columns it projects 
(timestamp_millis, duration, query)")
+       assert.Equal(t, []bool{true, true, false}, verdict.Keep)
+}
+
+// TestZipkinTraceSampler_KeepErrors covers keepErrors on a schema with no 
error
+// column: it detects Zipkin's conventional "error" span tag, which OAP writes 
into
+// "query" as both a bare key and "error=<message>". healthySampleRate is 0 so 
a
+// keep can only come from the error signal.
+func TestZipkinTraceSampler_KeepErrors(t *testing.T) {
+       sampler, err := 
NewSampler([]byte(`{"keepErrors":true,"healthySampleRate":0}`))
+       require.NoError(t, err)
+
+       // OAP's normal output: the bare key plus "error=<message>".
+       both, e := sdktest.NewTrace("err-both").
+               Tag("query", []string{"error", "error=Connection refused", 
"http.method=GET"}).Build()
+       require.NoError(t, e)
+       // An empty error message still marks the span as failed.
+       empty, e := sdktest.NewTrace("err-empty").Tag("query", 
[]string{"error="}).Build()
+       require.NoError(t, e)
+       // A tag whose key merely starts with "error" must NOT count.
+       lookalike, e := sdktest.NewTrace("lookalike").
+               Tag("query", []string{"error_rate=0", 
"http.method=GET"}).Build()
+       require.NoError(t, e)
+       // A healthy span has no error entry at all.
+       healthy, e := sdktest.NewTrace("healthy").Tag("query", 
[]string{"http.status_code=200"}).Build()
+       require.NoError(t, e)
+
+       verdict, report := sdktest.Run(sampler, sdktest.Batch(both, empty, 
lookalike, healthy))
+       require.NoError(t, report.Err)
+       assert.Empty(t, report.ProjectionDivergedIDs,
+               "keepErrors must read only the projected query column")
+       assert.Equal(t, []bool{true, true, false, false}, verdict.Keep)
+
+       // keepErrors projects the array column, not a separate error column.
+       assert.ElementsMatch(t, []string{"query"}, sampler.Project().Tags)
+}
+
+func TestZipkinTraceSampler_Project(t *testing.T) {
+       sampler, err := NewSampler([]byte(config62))
+       require.NoError(t, err)
+       proj := sampler.Project()
+       assert.ElementsMatch(t, []string{"duration", "timestamp_millis", 
"query"}, proj.Tags)
+       assert.False(t, proj.SpanIDs)
+       assert.False(t, proj.Spans)
+}

Reply via email to