Skip to content

Commit e7322df

Browse files
authored
[AAD-36] Use anomaly scorer by default (#54716)
<!--Please give us some feedback on your experience writing this PR ! https://app.datadoghq.com/forms/43db4c02-6837-400c-8083-692e141b1b88 !--> ### What does this PR do? This uses anomaly scorer by default instead of time cluster which is now disabled by default. ### Motivation We want to enable the scorer by default since we rely on it for smart adaptive sampling feature so our current direction is towards detection change points instead of sending events when we have bad behaviors. ### Describe how you validated your changes Ran the benchmarks ### Additional Notes Scenario | Before F1 (time cluster) | After F1 (scorer) | Δ F1 -- | -- | -- | -- block-building-outage | 0.285714 | 0.634606 | +0.348892 cascading-payment-failure | 0.063224 | 0.000083 | -0.063141 cassandra-repair-degradation | 0.227017 | 0.000000 | -0.227017 dns-upstream-outage | 0.054111 | 0.297905 | +0.243794 kafka-partition-saturation | 0.177494 | 0.577355 | +0.399861 lock-contention | 0.618715 | 0.032148 | -0.586567 memcached-saturation | 0.000006 | 0.557888 | +0.557882 pool-saturation | 0.000259 | 0.814789 | +0.814530 redis-cascade-billing | ~0 | ~0 | ~0 redis-cpu-saturation | 0.615742 | 0.016948 | -0.598794 tiered-cache-header-corruption | 0.001845 | ~0 | -0.001845 **Average** | 0.185830 | 0.266520 | +0.080690 (+43.42%) Co-authored-by: celian.raimbault <celian.raimbault@datadoghq.com>
1 parent fec92d8 commit e7322df

9 files changed

Lines changed: 83 additions & 34 deletions

File tree

comp/anomalydetection/observer/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ and the testbench use the same engine.
4141
| `impl/agent_logs.go` | Agent internal log tap (source: `agent_logs`) |
4242
| `impl/log_pattern_extractor.go` | Log → virtual metrics via pattern clustering |
4343
| `impl/log_metrics_extractor.go` | Log → virtual metrics via regex extraction |
44-
| `impl/anomaly_correlator_time_cluster.go` | Default time-proximity correlator |
44+
| `impl/anomaly_correlator_time_cluster.go` | Time-proximity correlator |
4545
| `impl/anomaly_correlator_passthrough.go` | Passthrough correlator (one ActiveCorrelation per anomaly) |
4646
| `impl/anomaly_scorer.go` | Unified EWMA anomaly scorer (Correlator + standalone replay); derives severity, delegates push subscriptions to `severityevents/impl.Dispatcher` |
4747
| `impl/correlation_emitter.go` | Shared first-seen/recurrence helper used by all non-scorer correlators |
@@ -59,7 +59,7 @@ Registered in `impl/component_catalog.go`. Enabled by default unless noted:
5959
| Detector | `bocpd` | on |
6060
| Detector | `rrcf` | on |
6161
| Detector | `cusum`, `scanmw`, `scanwelch`, `holt_residual`, `tukey_biweight` | off |
62-
| Correlator | `time_cluster` | on |
62+
| Correlator | `time_cluster` | off |
6363
| Correlator | `cross_signal`, `passthrough` | off |
6464
| Correlator | `anomaly_scorer` | off |
6565

comp/anomalydetection/observer/impl/component_catalog.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,14 @@ type ComponentSettings struct {
9090
configs map[string]any
9191
}
9292

93-
// ApplyTestbenchDetectorDefaults applies the shorter detector warmups used by
94-
// the offline testbench. Production builds ComponentSettings from the agent
95-
// config and never calls this function. An explicit testbench --config entry
96-
// for a detector takes precedence over this profile.
97-
func ApplyTestbenchDetectorDefaults(settings ComponentSettings) ComponentSettings {
93+
// ApplyTestbenchDefaults applies replay-specific defaults used by the offline
94+
// testbench. Production builds ComponentSettings from the agent config and
95+
// never calls this function. Explicit testbench --config component entries
96+
// take precedence over this profile.
97+
func ApplyTestbenchDefaults(settings ComponentSettings) ComponentSettings {
98+
if settings.Enabled == nil {
99+
settings.Enabled = make(map[string]bool)
100+
}
98101
if settings.configs == nil {
99102
settings.configs = make(map[string]any)
100103
}
@@ -117,6 +120,19 @@ func ApplyTestbenchDetectorDefaults(settings ComponentSettings) ComponentSetting
117120
settings.configs[name] = cfg
118121
}
119122
}
123+
124+
// Evals should score the scorer's correlation episodes. Preserve explicit
125+
// component choices in --config, which are used for ablations and manual
126+
// comparisons.
127+
if _, explicitlyEnabled := settings.Enabled["anomaly_scorer"]; !explicitlyEnabled {
128+
settings.Enabled["anomaly_scorer"] = true
129+
}
130+
if _, explicitlyConfigured := settings.configs["anomaly_scorer"]; !explicitlyConfigured {
131+
scorer := DefaultAnomalyScorerConfig()
132+
scorer.CorrelationEvents = true
133+
scorer.CooldownSecs = 0
134+
settings.configs["anomaly_scorer"] = scorer
135+
}
120136
return settings
121137
}
122138

@@ -291,7 +307,7 @@ func defaultCatalog() *componentCatalog {
291307
kind: componentCorrelator,
292308
defaultConfig: DefaultTimeClusterConfig(),
293309
factory: func(cfg any) any { return NewTimeClusterCorrelator(cfg.(TimeClusterConfig)) },
294-
defaultEnabled: true,
310+
defaultEnabled: false,
295311
readConfig: readTimeClusterConfig,
296312
parseJSON: func(defaults any, raw []byte) (any, error) {
297313
cfg := defaults.(TimeClusterConfig)

comp/anomalydetection/observer/impl/component_catalog_test.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ func TestValidateDetectorTeardownContract_AllowlistEscape(t *testing.T) {
6767
require.NoError(t, cat.validateDetectorTeardownContract())
6868
}
6969

70-
func TestApplyTestbenchDetectorDefaults(t *testing.T) {
71-
settings := ApplyTestbenchDetectorDefaults(ComponentSettings{})
70+
func TestApplyTestbenchDefaults(t *testing.T) {
71+
settings := ApplyTestbenchDefaults(ComponentSettings{})
7272

7373
require.Equal(t, 40, settings.configs["bocpd"].(BOCPDConfig).WarmupPoints)
7474
holt := settings.configs["holt_residual"].(HoltResidualConfig)
@@ -77,16 +77,25 @@ func TestApplyTestbenchDetectorDefaults(t *testing.T) {
7777
tukey := settings.configs["tukey_biweight"].(TukeyBiweightConfig)
7878
require.Equal(t, 40, tukey.WindowSize)
7979
require.Equal(t, 40, tukey.MinPoints)
80+
require.True(t, settings.Enabled["anomaly_scorer"])
81+
require.NotContains(t, settings.Enabled, "time_cluster")
82+
scorer := settings.configs["anomaly_scorer"].(AnomalyScorerConfig)
83+
require.True(t, scorer.CorrelationEvents)
84+
require.Zero(t, scorer.CooldownSecs)
8085
}
8186

82-
func TestApplyTestbenchDetectorDefaults_PreservesExplicitConfig(t *testing.T) {
87+
func TestApplyTestbenchDefaults_PreservesExplicitConfig(t *testing.T) {
8388
settings, err := ParseSettingsFromJSON(map[string]json.RawMessage{
84-
"bocpd": json.RawMessage(`{"warmup_points": 42}`),
89+
"bocpd": json.RawMessage(`{"warmup_points": 42}`),
90+
"anomaly_scorer": json.RawMessage(`{"enabled":false}`),
91+
"time_cluster": json.RawMessage(`{"enabled":true}`),
8592
})
8693
require.NoError(t, err)
8794

88-
settings = ApplyTestbenchDetectorDefaults(settings)
95+
settings = ApplyTestbenchDefaults(settings)
8996
require.Equal(t, 42, settings.configs["bocpd"].(BOCPDConfig).WarmupPoints)
97+
require.False(t, settings.Enabled["anomaly_scorer"])
98+
require.True(t, settings.Enabled["time_cluster"])
9099
}
91100

92101
// bareDetectorForValidator is a minimal observerdef.Detector that

internal/qbranch/anomalydetection-testbench/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ $ dda inv anomalydetection.eval-component-workspace-report evals # This will fet
9191

9292
| Name | Default | Description |
9393
|------|---------|-------------|
94-
| `time_cluster` | enabled | Groups anomalies that occur close together in time |
94+
| `anomaly_scorer` | enabled | Produces anomaly periods from the EWMA anomaly-severity score |
95+
| `time_cluster` | disabled | Groups anomalies that occur close together in time |
9596
| `cross_signal` | disabled | Cross-signal pattern correlator (fixed known patterns) |
9697
| `passthrough` | disabled | Passes every anomaly through as its own correlation (for TP metric scoring) |
9798

@@ -108,7 +109,7 @@ Extractors are always enabled and convert raw observations into timeseries:
108109
## Examples
109110

110111
```bash
111-
# Run with all defaults (bocpd + rrcf + time_cluster)
112+
# Run with all defaults (bocpd + rrcf + anomaly_scorer)
112113
dda inv -- anomalydetection.launch-testbench
113114

114115
# Only BOCPD + TimeCluster

internal/qbranch/anomalydetection-testbench/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func main() {
150150
MuteNoisyMetrics: *muteNoisyMetrics,
151151
}
152152
}
153-
componentSettings = observerimpl.ApplyTestbenchDetectorDefaults(componentSettings)
153+
componentSettings = observerimpl.ApplyTestbenchDefaults(componentSettings)
154154

155155
if *headless == "" {
156156
fmt.Printf("Observer Test Bench\n")

pkg/config/schema/yaml/core_schema.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5193,7 +5193,7 @@ properties:
51935193
enabled:
51945194
node_type: setting
51955195
type: boolean
5196-
default: true
5196+
default: false
51975197
min_cluster_size:
51985198
node_type: setting
51995199
type: integer

tasks/anomalydetection.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ def eval_scenarios(
205205
source of truth for anomaly detection accuracy.
206206
207207
Uses testbench --only to control which components are active.
208-
Default (no --only): uses testbench defaults (bocpd,rrcf,time_cluster + other default-enabled components).
208+
Default (no --only): uses testbench defaults (bocpd, rrcf, and
209+
anomaly_scorer; time_cluster is disabled).
209210
With --only: enables ONLY listed components + extractors, disables everything else.
210211
time_cluster is auto-added if not specified.
211212
With --config: JSON params file for testbench; overrides --only when both are set.

test/new-e2e/tests/anomalydetection/anomalydetection_nix_test.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,16 @@ func TestAnomalyDetectionMetricsTriggered(t *testing.T) {
5050
agentConfig := `
5151
log_level: debug
5252
anomaly_detection:
53-
anomaly_scorer:
54-
dry_run:
53+
reporting:
54+
events:
5555
enabled: true
56+
anomaly_scorer:
57+
# CUSUM produces a single anomalous series in this test. Keep the scorer
58+
# thresholds below its first EWMA update so the report path sees High.
59+
low_threshold: 0.000001
60+
high_threshold: 0.00001
61+
output:
62+
correlation_events: true
5663
metrics:
5764
enabled: true
5865
logs:
@@ -75,7 +82,7 @@ anomaly_detection:
7582
}
7683

7784
// TestMetricsTriggeredEmitsOnDSDSpike sends a stable gauge baseline then a large
78-
// spike, expecting CUSUM to fire and the stdout reporter to emit its marker.
85+
// spike, expecting CUSUM to fire and the scorer to open an episode.
7986
//
8087
// Point counts: 15 baseline (well above the 5-point CUSUM minimum) followed by
8188
// 10 spike points — total ~25 seconds of data. The spike is 5000× the baseline
@@ -143,8 +150,8 @@ func (s *metricsTriggeredSuite) TestMetricsTriggeredEmitsOnDSDSpike() {
143150
s.T().Log("done sending metrics")
144151
}()
145152

146-
waitForReportsTelemetry(s)
147-
s.T().Log("reports telemetry detected")
153+
waitForScorerEpisode(s, metricName)
154+
s.T().Log("scorer episode detected")
148155
}
149156

150157
// logTriggeredSuite exercises the external log collection path of the observer.
@@ -187,9 +194,16 @@ log_level: debug
187194
logs_config:
188195
file_scan_period: 1
189196
anomaly_detection:
190-
anomaly_scorer:
191-
dry_run:
197+
reporting:
198+
events:
192199
enabled: true
200+
anomaly_scorer:
201+
# BOCPD produces one anomalous series. Cross High on its first EWMA update
202+
# so the reporter reliably observes a scorer correlation event.
203+
low_threshold: 0.000001
204+
high_threshold: 0.00001
205+
output:
206+
correlation_events: true
193207
metrics:
194208
enabled: false
195209
logs:
@@ -312,6 +326,6 @@ func (s *logTriggeredSuite) TestLogsTriggeredEmitsOnFileSpike() {
312326
s.T().Log("done writing log lines")
313327
}()
314328

315-
waitForReportsTelemetry(s)
316-
s.T().Log("reports telemetry detected via log trigger")
329+
waitForScorerEpisode(s, "filename:e2e-anomaly-test.log")
330+
s.T().Log("scorer episode detected via log trigger")
317331
}

test/new-e2e/tests/anomalydetection/helpers_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,18 @@ const (
4343
telemetryLogsIngested = "observer.logs.ingested"
4444
telemetryReportsEmitted = "observer.reports.emitted"
4545
telemetryReportsOngoing = "observer.reports.ongoing"
46-
4746
// scorerHelperEscalationMarker is emitted by anomalyScorer.OnSeverityTransition
4847
// when output.logs=true and the EWMA rises above low_threshold (an escalation event).
4948
// Logged at info level, captured by journald, and serves as the assertion target.
5049
// Full example: "[observer] anomaly scorer anomaly_scorer severity escalation to Medium (was Low, t=...)"
5150
scorerHelperEscalationMarker = "[observer] anomaly scorer anomaly_scorer severity escalation"
5251

52+
// scorerEpisodeStartedMarker is emitted by the stdout reporter when the
53+
// scorer opens an episode after reaching High severity. The reporter appends
54+
// either the scorer metadata or a multiline contributor summary, so retain
55+
// only the prefix common to both renderings.
56+
scorerEpisodeStartedMarker = "[observer] scorer episode started:"
57+
5358
// scorerHelperRegisteredMarker is logged once at agent startup when the
5459
// anomaly scorer is successfully wired with telemetry. Waiting for it
5560
// before sending metrics ensures the scorer is active.
@@ -174,14 +179,17 @@ func sendGauge(s observerTestSuite, name string, value float64) {
174179
}
175180
}
176181

177-
func waitForReportsTelemetry(s observerTestSuite) {
182+
func waitForScorerEpisode(s observerTestSuite, expectedAnomalySource string) {
178183
s.T().Helper()
179-
s.T().Log("waiting for observer reports telemetry...")
184+
s.T().Log("waiting for anomaly scorer episode...")
180185
s.EventuallyWithT(func(c *assert.CollectT) {
181186
assert.True(c, s.Env().Agent.Client.IsReady(), "agent should be ready")
182-
tel := observerTelemetryOutput(s)
183-
assert.True(c, containsMetric(tel, telemetryReportsEmitted),
184-
"observer telemetry should expose reports emitted counter after anomalies")
187+
out, err := s.Env().RemoteHost.Execute("sudo journalctl -u datadog-agent --no-pager")
188+
assert.NoError(c, err, "journalctl execution failed")
189+
assert.Contains(c, out, scorerEpisodeStartedMarker,
190+
"journal should contain the scorer episode-started marker after anomalies")
191+
assert.Contains(c, out, expectedAnomalySource,
192+
"journal should contain an anomaly from the test input source")
185193
}, 3*time.Minute, 5*time.Second)
186-
s.T().Log("observer reports telemetry detected")
194+
s.T().Log("anomaly scorer episode detected")
187195
}

0 commit comments

Comments
 (0)