Copilot commented on code in PR #1234:
URL: 
https://github.com/apache/skywalking-banyandb/pull/1234#discussion_r3670568489


##########
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 stays a plain `[]byte`, with no shared 
config struct.
 - **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 mechanism. Two things are **opt-in and 
default off**: the span-id column (`Projection.SpanIDs`) and the heavy 
span-body column (`Projection.Spans`). A subtlety in the native layout makes 
this matter: `spanIDs` and `spans` are encoded **together** in one data block 
(`mustWriteSpansTo`/`mustReadSpansFrom`, `banyand/trace/block.go`), so reading 
span ids is **not** free — requesting either one forces a read of the spans 
stream. Only `trace_id` and `MinTS`/`MaxTS` are genuinely intrinsic (they come 
from `bl
 ockMetadata` with no decode). So the tiers are: intrinsic-always (`trace_id`, 
`minTS`/`maxTS`), opt-in-by-name (tags), and opt-in-and-default-off (the spans 
stream — span ids and/or span bodies) — which is what makes spans *more* 
optional than tags. The declare-up-front handshake matches the 
projection-pushdown contracts in [DuckDB's C table 
API](https://duckdb.org/docs/stable/clients/c/table_functions) 
(`duckdb_init_get_column_index`) and [DataFusion's 
`TableProvider::scan`](https://datafusion.apache.org/library-user-guide/custom-table-providers.html)
 (`projection: Option<&Vec<usize>>`). `min_duration`-style checks are free from 
`minTS`/`maxTS`, and an error predicate is just a projected tag (e.g. 
`is_error`), so a plugin that requests neither tags, span ids, nor span bodies 
stays on the merge raw fast path (`mustReadRaw` → `mustWriteRawBlock`, §7.1) 
and pays no decode at all.
+**(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 mechanism. Two things are **opt-in and 
default off**: the span-id column (`Projection.SpanIDs`) and the heavy 
span-body column (`Projection.Spans`). A subtlety in the native layout makes 
this matter: `spanIDs` and `spans` are encoded **together** in one data block 
(`mustWriteSpansTo`/`mustReadSpansFrom`, `banyand/trace/block.go`), so reading 
span ids is **not** free — requesting either one forces a read of the spans 
stream. Only `trace_id` and `MinTS`/`MaxTS` are genuinely intrinsic (they come 
from `bl
 ockMetadata` with no decode). So the tiers are: intrinsic-always (`trace_id`, 
`minTS`/`maxTS`), opt-in-by-name (tags), and opt-in-and-default-off (the spans 
stream — span ids and/or span bodies) — which is what makes spans *more* 
optional than tags. The declare-up-front handshake matches the 
projection-pushdown contracts in [DuckDB's C table 
API](https://duckdb.org/docs/stable/clients/c/table_functions) 
(`duckdb_init_get_column_index`) and [DataFusion's 
`TableProvider::scan`](https://datafusion.apache.org/library-user-guide/custom-table-providers.html)
 (`projection: Option<&Vec<usize>>`). an error predicate is just a projected 
tag (e.g. `is_error`), and while `minTS`/`maxTS` are free, they are the spread 
of per-row *start* timestamps — **not** a trace duration (they are 0 for a 
single-row trace), so a duration predicate must project the schema's own 
start/duration tags, so a plugin that requests neither tags, span ids, nor span 
bodies stays on the merge raw fast path (`mustRea
 dRaw` → `mustWriteRawBlock`, §7.1) and pays no decode at all.

Review Comment:
   Sentence starts mid-line after a period; this looks like a typo/grammar 
issue in the design doc. Capitalize "An" to keep the paragraph readable.



##########
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,
+       }

Review Comment:
   This does not compile: c.KeepTagRules has the named slice type rules, but 
Sampler.rules is []rule, so the composite literal assignment is not assignable. 
Convert explicitly (or change the field type) so the plugin builds.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to