include db for gitea build
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m16s

This commit is contained in:
mats committed 2026-09-20 11:01:56 +02:00
1 parent 8de4a2e010
commit 645f923175
33 files changed
+1490 -150

No files matched your search

+51 -1
View File
@@ -7,6 +7,8 @@ package features
import (
"sort"
"time"
"git.tornberg.me/go-otel/internal/uaclass"
)
// Event is the subset of a normalized event used by the detector. Text fields
@@ -31,6 +33,14 @@ type Event struct {
Route string
StatusCode int
ExceptionType string
// Trace-resolved context (see internal/tracectx). All optional: zero
// values mean "not resolved", which behaves like the pre-resolution data.
ClientIP string // real client behind proxy hops, when known
UAClass string // internal/uaclass class of the User-Agent
IsDuplicate bool // repeats an ancestor span's failure; not a new request
IsRootCause bool // error span with no erroring child
HasUpstream bool // an ingress span that had a child in another service
}
// Config controls feature and label windows. A zero Config receives the
@@ -94,6 +104,18 @@ type Row struct {
TimeoutCount int `json:"timeoutCount"`
CancellationCount int `json:"cancellationCount"`
// Logical-request view: DuplicateErrors are hops repeating an ancestor's
// failure, so LogicalErrors = ErrorCount - DuplicateErrors is the number of
// failed requests. DistinctClients / TopClientShare separate one noisy
// client from broad impact; BotErrorShare is the share of errors whose
// User-Agent classifies as a self-declared non-human client.
LogicalErrors int `json:"logicalErrors"`
DuplicateErrors int `json:"duplicateErrors"`
RootCauseErrors int `json:"rootCauseErrors"`
DistinctClients int `json:"distinctClients"`
TopClientShare float64 `json:"topClientShare"`
BotErrorShare float64 `json:"botErrorShare"`
BaselineErrorRate float64 `json:"baselineErrorRate"`
BaselineSamples int `json:"baselineSamples"`
FutureEventCount int `json:"futureEventCount"`
@@ -189,10 +211,13 @@ type accumulator struct {
durations []float64
traces map[string]struct{}
issues map[string]struct{}
clients map[string]int
botErrors int
classed int
}
func newAccumulator(start, end time.Time, service, release string) *accumulator {
return &accumulator{value: Row{WindowStart: start, WindowEnd: end, Service: service, Release: release}, traces: map[string]struct{}{}, issues: map[string]struct{}{}}
return &accumulator{value: Row{WindowStart: start, WindowEnd: end, Service: service, Release: release}, traces: map[string]struct{}{}, issues: map[string]struct{}{}, clients: map[string]int{}}
}
func (a *accumulator) add(e Event) {
@@ -223,6 +248,20 @@ func (a *accumulator) add(e Event) {
return
}
a.value.ErrorCount++
if e.IsDuplicate {
a.value.DuplicateErrors++
} else if e.ClientIP != "" {
a.clients[e.ClientIP]++
}
if e.IsRootCause {
a.value.RootCauseErrors++
}
if e.UAClass != "" && !e.IsDuplicate {
a.classed++
if uaclass.IsBot(e.UAClass) {
a.botErrors++
}
}
if e.Source == "frontend" {
a.value.FrontendErrors++
} else {
@@ -252,6 +291,17 @@ func issueKey(e Event) string {
func (a *accumulator) row() Row {
a.value.ErrorRate = rate(a.value.ErrorCount, a.value.TotalEvents)
a.value.UniqueTraceCount, a.value.UniqueIssueCount = len(a.traces), len(a.issues)
a.value.LogicalErrors = a.value.ErrorCount - a.value.DuplicateErrors
a.value.DistinctClients = len(a.clients)
top, known := 0, 0
for _, n := range a.clients {
known += n
if n > top {
top = n
}
}
a.value.TopClientShare = rate(top, known)
a.value.BotErrorShare = rate(a.botErrors, a.classed)
if len(a.durations) != 0 {
sort.Float64s(a.durations)
a.value.P50Duration = percentile(a.durations, .50)
+21
View File
@@ -54,3 +54,24 @@ func TestBuildWithholdsLabelWithoutCompleteBaseline(t *testing.T) {
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)
}
}
+14 -3
View File
@@ -33,7 +33,8 @@ type PatternDelta struct {
Count int `json:"count"`
BaselineMean float64 `json:"baselineMean"`
Excess float64 `json:"excess"`
Novel bool `json:"novel"` // never seen in the baseline
Novel bool `json:"novel"` // never seen in the baseline
Clients int `json:"clients,omitempty"` // distinct resolved client IPs in the current window
Example Event `json:"example"`
}
@@ -84,6 +85,7 @@ func DetectSpikes(events []Event, at time.Time, cfg SpikeConfig) []Spike {
cur int
base []int // per baseline window
example Event
clients map[string]struct{}
}
type svc struct {
cur int
@@ -92,7 +94,10 @@ func DetectSpikes(events []Event, at time.Time, cfg SpikeConfig) []Spike {
}
services := map[string]*svc{}
for _, e := range events {
if !e.IsError {
// A duplicate is a later hop of a request whose outermost error is
// already counted; counting it again multiplies every failure by the
// number of services it crossed.
if !e.IsError || e.IsDuplicate {
continue
}
ts := e.Timestamp.UTC()
@@ -123,6 +128,12 @@ func DetectSpikes(events []Event, at time.Time, cfg SpikeConfig) []Spike {
s.cur++
p.cur++
p.example = e
if e.ClientIP != "" {
if p.clients == nil {
p.clients = map[string]struct{}{}
}
p.clients[e.ClientIP] = struct{}{}
}
} else {
idx := int(ts.Sub(baseStart) / cfg.Window)
if idx >= cfg.BaselineWindows {
@@ -154,7 +165,7 @@ func DetectSpikes(events []Event, at time.Time, cfg SpikeConfig) []Spike {
}
spike.Excess += excess
spike.Patterns = append(spike.Patterns, PatternDelta{
Signature: sig, Count: p.cur, BaselineMean: mean, Excess: excess, Novel: !seen, Example: p.example,
Signature: sig, Count: p.cur, BaselineMean: mean, Excess: excess, Novel: !seen, Example: p.example, Clients: len(p.clients),
})
}
if spike.Excess < float64(cfg.MinExcess) {
+23
View File
@@ -98,3 +98,26 @@ func TestDetectSpikesWarmupGuard(t *testing.T) {
t.Fatalf("guard should be off when DataSince is zero")
}
}
func TestDetectSpikesSkipsDuplicateHopsAndCountsClients(t *testing.T) {
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12}
var events []Event
for i := 0; i < 8; i++ {
ts := at.Add(-time.Minute).Add(time.Duration(i) * time.Second)
client := "203.0.113." + string(rune('1'+i%2))
events = append(events,
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client},
// two more hops of the same request must not count
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client, IsDuplicate: true},
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client, IsDuplicate: true},
)
}
got := DetectSpikes(events, at, cfg)
if len(got) != 1 || got[0].ErrorCount != 8 || got[0].Patterns[0].Count != 8 {
t.Fatalf("duplicates were counted: %+v", got)
}
if got[0].Patterns[0].Clients != 2 {
t.Fatalf("clients = %d, want 2", got[0].Patterns[0].Clients)
}
}