Skip to content

Commit 1c0c935

Browse files
committed
[AAD-21] Start baseline qualification on detector readiness
1 parent 6a6ef94 commit 1c0c935

24 files changed

Lines changed: 252 additions & 193 deletions

comp/anomalydetection/observer/def/types.go

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"sort"
1515
"strconv"
1616
"strings"
17-
"time"
1817

1918
severityeventsdef "github.com/DataDog/datadog-agent/comp/anomalydetection/severityevents/def"
2019
)
@@ -287,8 +286,9 @@ type DetectionResult struct {
287286
type SeriesDetector interface {
288287
// Name returns the analysis name for debugging.
289288
Name() string
290-
// BaselineSpec reports how long the detector needs to build a usable model.
291-
BaselineSpec() BaselineSpec
289+
// Ready reports whether at least one series has reached the detector's
290+
// actual scoring condition. It is monotonic until Reset.
291+
Ready() bool
292292
// Detect examines a series and returns any detected anomalies.
293293
Detect(series Series) DetectionResult
294294
}
@@ -573,19 +573,14 @@ type StorageReader interface {
573573
SeriesGeneration() uint64
574574
}
575575

576-
// BaselineSpec describes the detector's model warmup before its qualification
577-
// baseline begins.
578-
type BaselineSpec struct {
579-
WarmupDuration time.Duration
580-
}
581-
582576
// Detector is the flexible detection interface where detectors pull data from storage.
583577
// This supports multivariate detection across multiple series.
584578
type Detector interface {
585579
Name() string
586580

587-
// BaselineSpec reports how long the detector needs to build a usable model.
588-
BaselineSpec() BaselineSpec
581+
// Ready reports whether at least one series has reached the detector's
582+
// actual scoring condition. It is monotonic until Reset.
583+
Ready() bool
589584

590585
// Detect is called periodically by the scheduler.
591586
// The detector queries storage for whatever data it needs.

comp/anomalydetection/observer/impl/baseline.go

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@ package observerimpl
77

88
import (
99
"sort"
10-
"time"
1110

1211
observerdef "github.com/DataDog/datadog-agent/comp/anomalydetection/observer/def"
1312
)
1413

1514
// BaselineConfig controls detector-specific baseline qualification windows.
16-
// DurationSec is the qualification duration after a detector's own warmup.
15+
// DurationSec is the qualification duration after a detector becomes ready.
1716
type BaselineConfig struct {
1817
Enabled bool
1918
DurationSec int64
@@ -26,13 +25,8 @@ func DefaultBaselineConfig() BaselineConfig {
2625
return BaselineConfig{Enabled: true, DurationSec: 600, MuteNoisyMetrics: true}
2726
}
2827

29-
// baselineReferenceInterval translates point-count detector requirements into
30-
// data-time warmups. It is deliberately a scheduling contract, not a claim
31-
// about the cadence of every incoming series.
32-
const baselineReferenceInterval = 15 * time.Second
33-
3428
type detectorBaselineState struct {
35-
spec observerdef.BaselineSpec
29+
ready bool
3630
warmupEndSec int64
3731
baselineEndSec int64
3832
completed bool
@@ -44,19 +38,21 @@ type detectorBaselineState struct {
4438
// BaselineDetectorDebugStatus is a testbench-facing snapshot of one detector.
4539
type BaselineDetectorDebugStatus struct {
4640
Name string `json:"name"`
47-
WarmupEndSec int64 `json:"warmupEndSec"`
48-
BaselineEndSec int64 `json:"baselineEndSec"`
41+
Ready bool `json:"ready"`
42+
WarmupEndSec int64 `json:"warmupEndSec,omitempty"`
43+
BaselineEndSec int64 `json:"baselineEndSec,omitempty"`
4944
Completed bool `json:"completed"`
5045
MutedCount int `json:"mutedCount"`
5146
}
5247

5348
// BaselineDebugStatus is a testbench-facing snapshot of the baseline union.
5449
type BaselineDebugStatus struct {
55-
Started bool `json:"started"`
56-
StartSec int64 `json:"startSec"`
57-
AllComplete bool `json:"allComplete"`
58-
MutedCount int `json:"mutedCount"`
59-
Detectors []BaselineDetectorDebugStatus `json:"detectors"`
50+
Started bool `json:"started"`
51+
StartSec int64 `json:"startSec"`
52+
AnalyzedThroughSec int64 `json:"analyzedThroughSec,omitempty"`
53+
AllComplete bool `json:"allComplete"`
54+
MutedCount int `json:"mutedCount"`
55+
Detectors []BaselineDetectorDebugStatus `json:"detectors"`
6056
}
6157

6258
// baselineController coordinates independent detector windows. All methods
@@ -82,38 +78,43 @@ type baselineController struct {
8278
mutedNames map[string]struct{} // allocated only for the final verbose summary
8379
}
8480

85-
func newBaselineController(cfg BaselineConfig, detectors []detectorBaselineSpecEntry) *baselineController {
81+
func newBaselineController(cfg BaselineConfig, detectorNames []string) *baselineController {
8682
b := &baselineController{config: cfg, detectors: make(map[string]*detectorBaselineState), mutedHashes: make(map[uint64]struct{})}
87-
for _, d := range detectors {
88-
b.detectors[d.name] = &detectorBaselineState{spec: d.spec, pendingHashes: make(map[uint64]struct{})}
83+
for _, name := range detectorNames {
84+
b.detectors[name] = &detectorBaselineState{pendingHashes: make(map[uint64]struct{})}
8985
}
9086
return b
9187
}
9288

93-
type detectorBaselineSpecEntry struct {
94-
name string
95-
spec observerdef.BaselineSpec
96-
}
97-
98-
func baselineSpecs(detectors []observerdef.Detector) []detectorBaselineSpecEntry {
99-
entries := make([]detectorBaselineSpecEntry, 0, len(detectors))
89+
func detectorNames(detectors []observerdef.Detector) []string {
90+
names := make([]string, 0, len(detectors))
10091
for _, detector := range detectors {
101-
entries = append(entries, detectorBaselineSpecEntry{name: detector.Name(), spec: detector.BaselineSpec()})
92+
names = append(names, detector.Name())
10293
}
103-
return entries
94+
return names
10495
}
10596

106-
// start seeds all detector windows from the first analysis data timestamp.
97+
// start records the first analysis timestamp. Individual qualification windows
98+
// begin only when their detector reports that it is ready to score.
10799
func (b *baselineController) start(dataSec int64) {
108100
if b.started {
109101
return
110102
}
111103
b.started = true
112104
b.startSec = dataSec
113-
for _, state := range b.detectors {
114-
state.warmupEndSec = dataSec + int64(state.spec.WarmupDuration/time.Second)
115-
state.baselineEndSec = state.warmupEndSec + b.config.DurationSec
105+
}
106+
107+
// ready records a detector's first usable scoring advance and starts its
108+
// qualification baseline. It returns true only for that first transition.
109+
func (b *baselineController) ready(name string, dataSec int64) bool {
110+
state := b.detectors[name]
111+
if state == nil || state.ready {
112+
return false
116113
}
114+
state.ready = true
115+
state.warmupEndSec = dataSec
116+
state.baselineEndSec = dataSec + b.config.DurationSec
117+
return true
117118
}
118119

119120
// isAnalyzingAt reports whether the detector's baseline decision is still in
@@ -123,12 +124,15 @@ func (b *baselineController) isAnalyzingAt(name string, dataSec int64) bool {
123124
if state == nil || state.completed {
124125
return false
125126
}
127+
if !state.ready {
128+
return true
129+
}
126130
return dataSec < state.baselineEndSec
127131
}
128132

129133
func (b *baselineController) mark(name string, h uint64) {
130134
state := b.detectors[name]
131-
if state == nil || state.completed {
135+
if state == nil || state.completed || !state.ready {
132136
return
133137
}
134138
state.windowAnomalyCount++
@@ -138,7 +142,7 @@ func (b *baselineController) mark(name string, h uint64) {
138142
func (b *baselineController) due(dataSec int64) []string {
139143
var names []string
140144
for name, state := range b.detectors {
141-
if !state.completed && dataSec >= state.baselineEndSec {
145+
if state.ready && !state.completed && dataSec >= state.baselineEndSec {
142146
names = append(names, name)
143147
}
144148
}
@@ -147,7 +151,7 @@ func (b *baselineController) due(dataSec int64) []string {
147151

148152
func (b *baselineController) complete(name string) (newHashes map[uint64]struct{}, snapshotChanged bool, anomalyCount int, allComplete bool) {
149153
state := b.detectors[name]
150-
if state == nil || state.completed {
154+
if state == nil || !state.ready || state.completed {
151155
return nil, false, 0, b.allComplete()
152156
}
153157
state.completed = true
@@ -198,7 +202,7 @@ func (b *baselineController) debugStatus() BaselineDebugStatus {
198202
if state.completed {
199203
mutedCount = state.mutedCount
200204
}
201-
status.Detectors = append(status.Detectors, BaselineDetectorDebugStatus{Name: name, WarmupEndSec: state.warmupEndSec, BaselineEndSec: state.baselineEndSec, Completed: state.completed, MutedCount: mutedCount})
205+
status.Detectors = append(status.Detectors, BaselineDetectorDebugStatus{Name: name, Ready: state.ready, WarmupEndSec: state.warmupEndSec, BaselineEndSec: state.baselineEndSec, Completed: state.completed, MutedCount: mutedCount})
202206
}
203207
sort.Slice(status.Detectors, func(i, j int) bool { return status.Detectors[i].Name < status.Detectors[j].Name })
204208
return status

comp/anomalydetection/observer/impl/baseline_test.go

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package observerimpl
77

88
import (
99
"testing"
10-
"time"
1110

1211
"github.com/stretchr/testify/assert"
1312
"github.com/stretchr/testify/require"
@@ -27,7 +26,8 @@ type alwaysFiringDetector struct {
2726
// records series reclamation from another detector's baseline completion.
2827
type baselineTestDetector struct {
2928
name string
30-
spec observerdef.BaselineSpec
29+
readyAtSec int64
30+
ready bool
3131
source observerdef.SeriesDescriptor
3232
ref observerdef.SeriesRef
3333
emitAfterSec int64
@@ -36,10 +36,11 @@ type baselineTestDetector struct {
3636
}
3737

3838
func (d *baselineTestDetector) Name() string { return d.name }
39-
func (d *baselineTestDetector) BaselineSpec() observerdef.BaselineSpec {
40-
return d.spec
41-
}
39+
func (d *baselineTestDetector) Ready() bool { return d.ready }
4240
func (d *baselineTestDetector) Detect(_ observerdef.StorageReader, dataSec int64) observerdef.DetectionResult {
41+
if dataSec >= d.readyAtSec {
42+
d.ready = true
43+
}
4344
if dataSec < d.emitAfterSec {
4445
return observerdef.DetectionResult{}
4546
}
@@ -59,9 +60,7 @@ func (d *baselineTestDetector) RemoveSeries(refs []observerdef.SeriesRef) {
5960
}
6061

6162
func (d *alwaysFiringDetector) Name() string { return "always_firing" }
62-
func (*alwaysFiringDetector) BaselineSpec() observerdef.BaselineSpec {
63-
return observerdef.BaselineSpec{}
64-
}
63+
func (*alwaysFiringDetector) Ready() bool { return true }
6564
func (d *alwaysFiringDetector) Detect(_ observerdef.StorageReader, dataTime int64) observerdef.DetectionResult {
6665
return observerdef.DetectionResult{
6766
Anomalies: []observerdef.Anomaly{{
@@ -109,21 +108,20 @@ func makeBaselineEngine(cfg BaselineConfig, correlator observerdef.Correlator) (
109108

110109
// ---- baselineController unit tests ----
111110

112-
func TestBaselineController_DetectorSpecificWindows(t *testing.T) {
113-
b := newBaselineController(BaselineConfig{DurationSec: 600}, []detectorBaselineSpecEntry{
114-
{name: "fast", spec: observerdef.BaselineSpec{}},
115-
{name: "slow", spec: observerdef.BaselineSpec{WarmupDuration: 5 * time.Minute}},
116-
})
111+
func TestBaselineController_DetectorReadinessStartsIndependentWindows(t *testing.T) {
112+
b := newBaselineController(BaselineConfig{DurationSec: 600}, []string{"fast", "slow"})
117113
assert.False(t, b.debugStatus().Started)
118114
b.start(1000)
119115
status := b.debugStatus()
120116
assert.True(t, status.Started)
121117
require.Len(t, status.Detectors, 2)
122-
assert.Equal(t, BaselineDetectorDebugStatus{Name: "fast", WarmupEndSec: 1000, BaselineEndSec: 1600}, status.Detectors[0])
123-
assert.Equal(t, BaselineDetectorDebugStatus{Name: "slow", WarmupEndSec: 1300, BaselineEndSec: 1900}, status.Detectors[1])
118+
assert.Equal(t, BaselineDetectorDebugStatus{Name: "fast"}, status.Detectors[0])
119+
assert.Equal(t, BaselineDetectorDebugStatus{Name: "slow"}, status.Detectors[1])
124120
assert.True(t, b.isAnalyzingAt("fast", 1000))
125121
assert.True(t, b.isAnalyzingAt("slow", 1299))
126-
assert.True(t, b.isAnalyzingAt("slow", 1300))
122+
assert.True(t, b.ready("fast", 1000))
123+
assert.False(t, b.ready("fast", 1001))
124+
assert.True(t, b.ready("slow", 1300))
127125
assert.True(t, b.isAnalyzingAt("slow", 1899))
128126
assert.False(t, b.isAnalyzingAt("slow", 1900))
129127
assert.False(t, b.isAnalyzingAt("unknown", 1000))
@@ -147,12 +145,26 @@ func TestBaselineController_DetectorSpecificWindows(t *testing.T) {
147145
assert.True(t, b.debugStatus().AllComplete)
148146
}
149147

148+
func TestBaselineController_WaitingDetectorSuppressesWithoutMuting(t *testing.T) {
149+
b := newBaselineController(BaselineConfig{DurationSec: 60}, []string{"waiting"})
150+
b.start(100)
151+
152+
assert.True(t, b.isAnalyzingAt("waiting", 100))
153+
b.mark("waiting", 1) // defensive suppression before Ready must not mute
154+
assert.Empty(t, b.detectors["waiting"].pendingHashes)
155+
assert.Empty(t, b.due(1_000))
156+
assert.False(t, b.allComplete())
157+
158+
b.ready("waiting", 130)
159+
b.mark("waiting", 1)
160+
assert.Equal(t, []string{"waiting"}, b.due(190))
161+
}
162+
150163
func TestBaselineController_CompletionPublishesImmutableUnionAndReleasesPendingHashes(t *testing.T) {
151-
b := newBaselineController(BaselineConfig{DurationSec: 600}, []detectorBaselineSpecEntry{
152-
{name: "first", spec: observerdef.BaselineSpec{}},
153-
{name: "second", spec: observerdef.BaselineSpec{}},
154-
})
164+
b := newBaselineController(BaselineConfig{DurationSec: 600}, []string{"first", "second"})
155165
b.start(1000)
166+
b.ready("first", 1000)
167+
b.ready("second", 1000)
156168
b.mark("first", 1)
157169
b.mark("second", 1)
158170
b.mark("second", 2)
@@ -178,11 +190,10 @@ func TestBaselineController_CompletionPublishesImmutableUnionAndReleasesPendingH
178190
}
179191

180192
func TestBaselineController_DuplicateCompletionDoesNotReplaceUnionSnapshot(t *testing.T) {
181-
b := newBaselineController(BaselineConfig{DurationSec: 600}, []detectorBaselineSpecEntry{
182-
{name: "first", spec: observerdef.BaselineSpec{}},
183-
{name: "second", spec: observerdef.BaselineSpec{}},
184-
})
193+
b := newBaselineController(BaselineConfig{DurationSec: 600}, []string{"first", "second"})
185194
b.start(1000)
195+
b.ready("first", 1000)
196+
b.ready("second", 1000)
186197
b.mark("first", 1)
187198
b.mark("second", 1)
188199

@@ -204,13 +215,6 @@ func TestBaselineController_VerboseNamesAreLazyAndReleased(t *testing.T) {
204215
assert.Nil(t, b.mutedNames)
205216
}
206217

207-
func TestRRCFBaselineSpec_UsesAlignedReadiness(t *testing.T) {
208-
rrcf := NewRRCFDetector(RRCFConfig{NumTrees: 1, TreeSize: 64, ShingleSize: 4})
209-
spec := rrcf.BaselineSpec()
210-
211-
assert.Equal(t, 78*baselineReferenceInterval, spec.WarmupDuration)
212-
}
213-
214218
// ---- engine integration tests ----
215219

216220
func TestBaseline_AnomaliesHeldDuringWindow(t *testing.T) {
@@ -233,14 +237,38 @@ func TestBaseline_AnomaliesForwardedAfterWindow(t *testing.T) {
233237
assert.NotEmpty(t, correlator.received)
234238
}
235239

240+
func TestBaseline_WaitingDetectorDoesNotMuteUntilReady(t *testing.T) {
241+
storage := newTimeSeriesStorage()
242+
ref := storage.Add("ns", "cpu", 1.0, 100, nil).Ref
243+
detector := &baselineTestDetector{
244+
name: "waiting",
245+
readyAtSec: 200,
246+
emitAfterSec: 100,
247+
includeSource: true,
248+
ref: ref,
249+
source: observerdef.SeriesDescriptor{Namespace: "ns", Name: "cpu", Aggregate: AggregateAverage},
250+
}
251+
e := newEngine(engineConfig{storage: storage, detectors: []observerdef.Detector{detector}, baseline: BaselineConfig{Enabled: true, DurationSec: 100, MuteNoisyMetrics: true}})
252+
253+
e.Advance(100) // detector emits before Ready; it is suppressed but cannot mute
254+
assert.Empty(t, e.baseline.mutedHashes)
255+
assert.False(t, e.baseline.detectors["waiting"].ready)
256+
257+
e.Advance(200) // readiness transition anomaly is included in qualification
258+
assert.True(t, e.baseline.detectors["waiting"].ready)
259+
assert.Len(t, e.baseline.detectors["waiting"].pendingHashes, 1)
260+
e.Advance(300)
261+
assert.Len(t, e.baseline.mutedHashes, 1)
262+
}
263+
236264
func TestBaseline_FastCompletionRemovesSeriesFromSlowerDetector(t *testing.T) {
237265
storage := newTimeSeriesStorage()
238266
ref := storage.Add("ns", "cpu", 1.0, 100, nil).Ref
239267
source := observerdef.SeriesDescriptor{Namespace: "ns", Name: "cpu", Aggregate: AggregateAverage}
240268
fast := &baselineTestDetector{name: "fast", source: source, ref: ref, includeSource: true}
241269
slow := &baselineTestDetector{
242270
name: "slow",
243-
spec: observerdef.BaselineSpec{WarmupDuration: 5 * time.Minute},
271+
readyAtSec: 400,
244272
source: source,
245273
ref: ref,
246274
includeSource: true,
@@ -272,7 +300,7 @@ func TestBaseline_FastDetectorForwardsWhileSlowerDetectorStillAnalyses(t *testin
272300
emitAfterSec: 200,
273301
includeSource: true,
274302
}
275-
slow := &baselineTestDetector{name: "slow", spec: observerdef.BaselineSpec{WarmupDuration: 5 * time.Minute}, emitAfterSec: 1<<62 - 1}
303+
slow := &baselineTestDetector{name: "slow", readyAtSec: 400, emitAfterSec: 1<<62 - 1}
276304
correlator := &recordingCorrelator{}
277305
e := newEngine(engineConfig{
278306
storage: storage,
@@ -382,9 +410,7 @@ func TestBaseline_MuteNoisyMetricsFalseDoesNotDropMetrics(t *testing.T) {
382410
type storageAwareDetector struct{}
383411

384412
func (d *storageAwareDetector) Name() string { return "storage_aware" }
385-
func (*storageAwareDetector) BaselineSpec() observerdef.BaselineSpec {
386-
return observerdef.BaselineSpec{}
387-
}
413+
func (*storageAwareDetector) Ready() bool { return true }
388414
func (d *storageAwareDetector) Detect(sr observerdef.StorageReader, dataTime int64) observerdef.DetectionResult {
389415
metas := sr.ListSeries(observerdef.SeriesFilter{})
390416
anomalies := make([]observerdef.Anomaly, 0, len(metas))

0 commit comments

Comments
 (0)