Skip to content

Commit 57d6a61

Browse files
[SYNTH-21264] Add assertions on synthetics tests. (#41271)
### What does this PR do? Now that we have the result from datadog-traceroute available, we need to code the Assertions evaluation. ### Motivation ### Describe how you validated your changes ### Additional Notes
1 parent 94483a7 commit 57d6a61

File tree

9 files changed

+622
-54
lines changed

9 files changed

+622
-54
lines changed

comp/syntheticstestscheduler/common/data.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ type SyntheticsTestConfig struct {
7070
Type string `json:"type"`
7171

7272
Config struct {
73-
Assertions []interface{} `json:"assertions"`
73+
Assertions []Assertion `json:"assertions"`
7474
Request ConfigRequest `json:"request"`
7575
} `json:"config"`
7676

@@ -80,6 +80,58 @@ type SyntheticsTestConfig struct {
8080
PublicID string `json:"publicID"`
8181
}
8282

83+
// Operator represents a comparison operator for assertions.
84+
type Operator string
85+
86+
const (
87+
// OperatorIs checks equality.
88+
OperatorIs Operator = "is"
89+
// OperatorIsNot checks inequality.
90+
OperatorIsNot Operator = "isNot"
91+
// OperatorMoreThan checks if greater than target.
92+
OperatorMoreThan Operator = "moreThan"
93+
// OperatorMoreThanOrEquals checks if greater than or equal to target.
94+
OperatorMoreThanOrEquals Operator = "moreThanOrEquals"
95+
// OperatorLessThan checks if less than target.
96+
OperatorLessThan Operator = "lessThan"
97+
// OperatorLessThanOrEquals checks if less than or equal to target.
98+
OperatorLessThanOrEquals Operator = "lessThanOrEquals"
99+
)
100+
101+
// AssertionType represents the type of metric being asserted in a network test.
102+
type AssertionType string
103+
104+
const (
105+
// AssertionTypeNetworkHops represents a network hops assertion.
106+
AssertionTypeNetworkHops AssertionType = "networkHops"
107+
// AssertionTypeLatency represents a latency assertion.
108+
AssertionTypeLatency AssertionType = "latency"
109+
// AssertionTypePacketLoss represents a packet loss percentage assertion.
110+
AssertionTypePacketLoss AssertionType = "packetLossPercentage"
111+
// AssertionTypePacketJitter represents a packet jitter assertion.
112+
AssertionTypePacketJitter AssertionType = "jitter"
113+
)
114+
115+
// AssertionSubType represents the aggregation type for an assertion.
116+
type AssertionSubType string
117+
118+
const (
119+
// AssertionSubTypeAverage represents the average value of the metric.
120+
AssertionSubTypeAverage AssertionSubType = "avg"
121+
// AssertionSubTypeMin represents the minimum value of the metric.
122+
AssertionSubTypeMin AssertionSubType = "min"
123+
// AssertionSubTypeMax represents the maximum value of the metric.
124+
AssertionSubTypeMax AssertionSubType = "max"
125+
)
126+
127+
// Assertion represents a single condition to be checked in a network test.
128+
type Assertion struct {
129+
Operator Operator `json:"operator"`
130+
Property AssertionSubType `json:"property"`
131+
Target string `json:"target"`
132+
Type AssertionType `json:"type"`
133+
}
134+
83135
// UnmarshalJSON is a Custom unmarshal for SyntheticsTestConfig
84136
func (c *SyntheticsTestConfig) UnmarshalJSON(data []byte) error {
85137
type rawConfig struct {
@@ -88,7 +140,7 @@ func (c *SyntheticsTestConfig) UnmarshalJSON(data []byte) error {
88140
Subtype string `json:"subtype"`
89141

90142
Config struct {
91-
Assertions []interface{} `json:"assertions"`
143+
Assertions []Assertion `json:"assertions"`
92144
Request json.RawMessage `json:"request"`
93145
} `json:"config"`
94146

comp/syntheticstestscheduler/common/result.go

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,88 @@
55

66
package common
77

8-
import "github.com/DataDog/datadog-agent/pkg/networkpath/payload"
9-
10-
// Assertion represents a validation check comparing expected and actual values.
11-
type Assertion struct {
12-
Operator string `json:"operator"`
13-
Type string `json:"type"`
14-
Expected interface{} `json:"expected"`
15-
Actual interface{} `json:"actual"`
16-
Valid bool `json:"valid"`
8+
import (
9+
"fmt"
10+
"reflect"
11+
"strconv"
12+
13+
"github.com/DataDog/datadog-agent/pkg/networkpath/payload"
14+
)
15+
16+
// AssertionResult represents a validation check comparing expected and actual values.
17+
type AssertionResult struct {
18+
Operator Operator `json:"operator"`
19+
Type AssertionType `json:"type"`
20+
Property AssertionSubType `json:"property"`
21+
Expected interface{} `json:"expected"`
22+
Actual interface{} `json:"actual"`
23+
Valid bool `json:"valid"`
24+
Failure APIFailure `json:"failure"`
25+
}
26+
27+
// Compare evaluates the assertion result by comparing the actual and expected values.
28+
// Sets the Valid field based on the comparison and returns an error if parsing fails.
29+
func (a *AssertionResult) Compare() error {
30+
// Special case: Is / IsNot
31+
if a.Operator == OperatorIs || a.Operator == OperatorIsNot {
32+
// Try numeric comparison first
33+
expNum, expErr := parseToFloat(a.Expected)
34+
actNum, actErr := parseToFloat(a.Actual)
35+
36+
if expErr == nil && actErr == nil {
37+
// Both numeric → compare as numbers
38+
a.Valid = (actNum == expNum)
39+
} else {
40+
// Otherwise → compare as raw values
41+
a.Valid = reflect.DeepEqual(a.Actual, a.Expected)
42+
}
43+
if a.Operator == OperatorIsNot {
44+
a.Valid = !a.Valid
45+
}
46+
return nil
47+
}
48+
49+
// For numeric operators (<, <=, >, >=)
50+
exp, err := parseToFloat(a.Expected)
51+
if err != nil {
52+
return fmt.Errorf("expected parse error: %w", err)
53+
}
54+
act, err := parseToFloat(a.Actual)
55+
if err != nil {
56+
return fmt.Errorf("actual parse error: %w", err)
57+
}
58+
59+
switch a.Operator {
60+
case OperatorLessThan:
61+
a.Valid = act < exp
62+
case OperatorLessThanOrEquals:
63+
a.Valid = act <= exp
64+
case OperatorMoreThan:
65+
a.Valid = act > exp
66+
case OperatorMoreThanOrEquals:
67+
a.Valid = act >= exp
68+
default:
69+
return fmt.Errorf("unsupported operator %v", a.Operator)
70+
}
71+
72+
return nil
73+
}
74+
75+
func parseToFloat(v interface{}) (float64, error) {
76+
switch x := v.(type) {
77+
case string:
78+
if i, err := strconv.ParseInt(x, 10, 64); err == nil {
79+
return float64(i), nil
80+
}
81+
if f, err := strconv.ParseFloat(x, 64); err == nil {
82+
return f, nil
83+
}
84+
return 0, fmt.Errorf("value must be numeric string, got: %q", x)
85+
case int, int64, float32, float64:
86+
return reflect.ValueOf(v).Convert(reflect.TypeOf(float64(0))).Float(), nil
87+
default:
88+
return 0, fmt.Errorf("unsupported type: %T", v)
89+
}
1790
}
1891

1992
// Request represents the network request.
@@ -41,7 +114,7 @@ type Result struct {
41114
TestFinishedAt int64 `json:"testFinishedAt"`
42115
TestStartedAt int64 `json:"testStartedAt"`
43116
TestTriggeredAt int64 `json:"testTriggeredAt"`
44-
Assertions []Assertion `json:"assertions"`
117+
Assertions []AssertionResult `json:"assertions"`
45118
Failure ErrorOrFailure `json:"failure"`
46119
Duration int64 `json:"duration"`
47120
Request Request `json:"request"`
@@ -60,6 +133,9 @@ type Test struct {
60133

61134
// TestResult represents the full test execution result including metadata.
62135
type TestResult struct {
136+
Location struct {
137+
ID string `json:"id"`
138+
} `json:"location"`
63139
DD map[string]interface{} `json:"_dd"` // TestRequestInternalFields
64140
Result Result `json:"result"`
65141
Test Test `json:"test"`
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025-present Datadog, Inc.
5+
6+
package common
7+
8+
import (
9+
"testing"
10+
)
11+
12+
func TestAssertionResult_Compare(t *testing.T) {
13+
tests := map[Operator][]struct {
14+
name string
15+
expected interface{}
16+
actual interface{}
17+
want bool
18+
wantErr bool
19+
}{
20+
OperatorLessThan: {
21+
{"actual less", 10, 5, true, false},
22+
{"actual equal", 10, 10, false, false},
23+
{"actual more", 10, 20, false, false},
24+
},
25+
OperatorLessThanOrEquals: {
26+
{"less is true", 10, 5, true, false},
27+
{"equal is true", 10, 10, true, false},
28+
{"more is false", 10, 20, false, false},
29+
},
30+
OperatorMoreThan: {
31+
{"more is true", 10, 20, true, false},
32+
{"equal is false", 10, 10, false, false},
33+
{"less is false", 10, 5, false, false},
34+
},
35+
OperatorMoreThanOrEquals: {
36+
{"more is true", 10, 20, true, false},
37+
{"equal is true", 10, 10, true, false},
38+
{"less is false", 10, 5, false, false},
39+
{"float equal", 5.5, 5.5, true, false},
40+
},
41+
OperatorIs: {
42+
{"equal ints", "10", "10", true, false},
43+
{"not equal", "10", "20", false, false},
44+
{"equal numeric different format", "100.0", "100", true, false},
45+
{"string equal", "foo", "foo", true, false},
46+
{"string not equal", "foo", "bar", false, false},
47+
},
48+
OperatorIsNot: {
49+
{"not equal", 10, 20, true, false},
50+
{"equal", 10, 10, false, false},
51+
},
52+
}
53+
54+
for op, cases := range tests {
55+
t.Run(string(op), func(t *testing.T) {
56+
for _, tt := range cases {
57+
t.Run(tt.name, func(t *testing.T) {
58+
ar := &AssertionResult{
59+
Operator: op,
60+
Expected: tt.expected,
61+
Actual: tt.actual,
62+
}
63+
err := ar.Compare()
64+
if (err != nil) != tt.wantErr {
65+
t.Fatalf("Compare() error = %v, wantErr %v", err, tt.wantErr)
66+
}
67+
if !tt.wantErr && ar.Valid != tt.want {
68+
t.Errorf("Compare() got Valid = %v, want %v", ar.Valid, tt.want)
69+
}
70+
})
71+
}
72+
})
73+
}
74+
75+
// Explicit test for unsupported operator
76+
t.Run("unsupported operator", func(t *testing.T) {
77+
ar := &AssertionResult{
78+
Operator: "invalidOperator",
79+
Expected: 10,
80+
Actual: 10,
81+
}
82+
err := ar.Compare()
83+
if err == nil {
84+
t.Fatalf("expected error for unsupported operator")
85+
}
86+
})
87+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025-present Datadog, Inc.
5+
6+
package syntheticstestschedulerimpl
7+
8+
import (
9+
"fmt"
10+
11+
"github.com/DataDog/datadog-agent/comp/syntheticstestscheduler/common"
12+
)
13+
14+
const (
15+
incorrectAssertion = "INCORRECT_ASSERTION"
16+
invalidTest = "INVALID_TEST"
17+
)
18+
19+
func runAssertions(cfg common.SyntheticsTestConfig, result common.NetStats) []common.AssertionResult {
20+
assertions := make([]common.AssertionResult, 0)
21+
for _, assertion := range cfg.Config.Assertions {
22+
assertions = append(assertions, runAssertion(assertion, result))
23+
}
24+
return assertions
25+
}
26+
27+
func runAssertion(assertion common.Assertion, stats common.NetStats) common.AssertionResult {
28+
var actual float64
29+
30+
switch assertion.Type {
31+
case common.AssertionTypePacketLoss:
32+
actual = float64(stats.PacketLossPercentage)
33+
case common.AssertionTypePacketJitter:
34+
actual = stats.Jitter
35+
case common.AssertionTypeLatency:
36+
switch assertion.Property {
37+
case common.AssertionSubTypeAverage:
38+
actual = stats.Latency.Avg
39+
case common.AssertionSubTypeMin:
40+
actual = stats.Latency.Min
41+
case common.AssertionSubTypeMax:
42+
actual = stats.Latency.Max
43+
default:
44+
return common.AssertionResult{
45+
Operator: assertion.Operator,
46+
Type: assertion.Type,
47+
Property: assertion.Property,
48+
Expected: assertion.Target,
49+
Failure: common.APIFailure{
50+
Code: invalidTest,
51+
Message: fmt.Sprintf("unsupported field: %s.%s", assertion.Type, assertion.Property),
52+
},
53+
}
54+
}
55+
case common.AssertionTypeNetworkHops:
56+
switch assertion.Property {
57+
case common.AssertionSubTypeAverage:
58+
actual = stats.Hops.Avg
59+
case common.AssertionSubTypeMin:
60+
actual = float64(stats.Hops.Min)
61+
case common.AssertionSubTypeMax:
62+
actual = float64(stats.Hops.Max)
63+
default:
64+
return common.AssertionResult{
65+
Operator: assertion.Operator,
66+
Type: assertion.Type,
67+
Property: assertion.Property,
68+
Expected: assertion.Target,
69+
Failure: common.APIFailure{
70+
Code: invalidTest,
71+
Message: fmt.Sprintf("unsupported field: %s.%s", assertion.Type, assertion.Property),
72+
},
73+
}
74+
}
75+
default:
76+
return common.AssertionResult{
77+
Operator: assertion.Operator,
78+
Type: assertion.Type,
79+
Property: assertion.Property,
80+
Expected: assertion.Target,
81+
Failure: common.APIFailure{
82+
Code: invalidTest,
83+
Message: fmt.Sprintf("unsupported field: %s", assertion.Type),
84+
},
85+
}
86+
}
87+
88+
assertionResult := common.AssertionResult{
89+
Operator: assertion.Operator,
90+
Type: assertion.Type,
91+
Property: assertion.Property,
92+
Expected: assertion.Target,
93+
Actual: actual,
94+
}
95+
if err := assertionResult.Compare(); err != nil {
96+
assertionResult.Failure = common.APIFailure{
97+
Code: incorrectAssertion,
98+
Message: err.Error(),
99+
}
100+
return assertionResult
101+
}
102+
return assertionResult
103+
}

0 commit comments

Comments
 (0)