110 lines
4.4 KiB
Go
110 lines
4.4 KiB
Go
package features
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuildLabelsSpikeUsingOnlyEarlierSameTimeWindows(t *testing.T) {
|
|
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
|
|
events := make([]Event, 0)
|
|
// The prior two daily windows have 10 events and one error each.
|
|
for day := 1; day <= 2; day++ {
|
|
for i := 0; i < 10; i++ {
|
|
events = append(events, Event{Timestamp: base.AddDate(0, 0, -day).Add(time.Duration(i) * time.Second), Service: "api", Kind: "span", IsError: i == 0, DurationMS: float64(i)})
|
|
}
|
|
}
|
|
// Current row is non-error telemetry. The following fifteen minutes have
|
|
// five errors out of ten events: 5x the 10% baseline and above the floor.
|
|
for i := 0; i < 10; i++ {
|
|
events = append(events, Event{Timestamp: base.Add(time.Duration(i) * time.Second), Service: "api", Release: "v2", Kind: "span", IsError: false, DurationMS: float64(i)})
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
events = append(events, Event{Timestamp: base.Add(5 * time.Minute).Add(time.Duration(i) * time.Second), Service: "api", Kind: "log", IsError: i < 5})
|
|
}
|
|
// The source export's explicit exclusive watermark proves that the rest of
|
|
// the future interval was observed even though it was quiet.
|
|
rows := Build(events, Config{Window: 5 * time.Minute, Step: 5 * time.Minute, Future: 15 * time.Minute, ObservedUntil: base.Add(20 * time.Minute), BaselineDays: 2, MinBaselineSamples: 2, SpikeMultiple: 3, MinFutureErrors: 5})
|
|
var row *Row
|
|
for i := range rows {
|
|
if rows[i].WindowStart.Equal(base) && rows[i].Service == "api" && rows[i].Release == "v2" {
|
|
row = &rows[i]
|
|
break
|
|
}
|
|
}
|
|
if row == nil {
|
|
t.Fatal("missing current api/v2 row")
|
|
}
|
|
if !row.LabelKnown || !row.Label {
|
|
t.Fatalf("expected a known positive label, got %+v", *row)
|
|
}
|
|
if row.BaselineErrorRate != .1 || row.FutureErrorRate != .5 || row.FutureErrorCount != 5 {
|
|
t.Fatalf("unexpected label inputs: %+v", *row)
|
|
}
|
|
if row.P50Duration != 4 || row.P95Duration != 8 || row.SpanCount != 10 {
|
|
t.Fatalf("unexpected fixed-width features: %+v", *row)
|
|
}
|
|
}
|
|
|
|
func TestBuildWithholdsLabelWithoutCompleteBaseline(t *testing.T) {
|
|
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{{Timestamp: base, Service: "api", Kind: "log"}, {Timestamp: base.Add(20 * time.Minute), Service: "other", Kind: "log"}}
|
|
rows := Build(events, Config{Window: 5 * time.Minute, Step: 5 * time.Minute, Future: 15 * time.Minute, BaselineDays: 7, MinBaselineSamples: 7})
|
|
if len(rows) == 0 || rows[0].LabelKnown {
|
|
t.Fatalf("short history must not be labelled: %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestBuildLogicalRequestFeatures(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
mk := func(client string, dup bool, class string) Event {
|
|
return Event{Timestamp: at, Service: "nginx", Kind: "span", IsError: true, PatternSignature: "p", ClientIP: client, IsDuplicate: dup, UAClass: class}
|
|
}
|
|
rows := Build([]Event{mk("a", false, "ai_crawler"), mk("a", true, "ai_crawler"), mk("a", false, "browser"), mk("b", false, "browser")}, Config{})
|
|
if len(rows) == 0 {
|
|
t.Fatal("no rows")
|
|
}
|
|
r := rows[0]
|
|
if r.ErrorCount != 4 || r.DuplicateErrors != 1 || r.LogicalErrors != 3 {
|
|
t.Fatalf("logical errors wrong: %+v", r)
|
|
}
|
|
if r.DistinctClients != 2 || r.TopClientShare < 0.66 || r.TopClientShare > 0.67 {
|
|
t.Fatalf("client features wrong: %+v", r)
|
|
}
|
|
if r.BotErrorShare < 0.33 || r.BotErrorShare > 0.34 {
|
|
t.Fatalf("bot share wrong: %v", r.BotErrorShare)
|
|
}
|
|
}
|
|
|
|
func TestBuildLabelIgnoresBadActorNoise(t *testing.T) {
|
|
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
|
|
var events []Event
|
|
mk := func(at time.Time, err bool, bad string) Event {
|
|
return Event{Timestamp: at, Service: "api", Kind: "span", IsError: err, BadActor: bad, PatternSignature: "p"}
|
|
}
|
|
// seven days of a quiet baseline, then a future window of pure bot errors
|
|
for d := 0; d <= 7; d++ {
|
|
day := base.AddDate(0, 0, d)
|
|
for i := 0; i < 50; i++ {
|
|
events = append(events, mk(day.Add(time.Duration(i)*time.Second), i == 0, ""))
|
|
}
|
|
}
|
|
target := base.AddDate(0, 0, 7)
|
|
for i := 0; i < 30; i++ {
|
|
events = append(events, mk(target.Add(6*time.Minute+time.Duration(i)*time.Second), true, BadActorBot))
|
|
}
|
|
rows := Build(events, Config{ObservedUntil: target.Add(time.Hour)})
|
|
var found bool
|
|
for _, r := range rows {
|
|
if r.WindowStart.Equal(target) && r.Service == "api" {
|
|
found = true
|
|
if !r.LabelKnown || !r.RawLabel || r.Label {
|
|
t.Fatalf("raw label should fire on bot errors and clean label must not: %+v", r)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("target row missing")
|
|
}
|
|
}
|