mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
The v5 logs and traces statement builders read event.log and event.span, where every row carries its org. Each read now binds `org = ?` to the tenant: the validated X-Org-Id for a request (set by the Hanzo IAM identity), or the rule's org name for a scheduled rule that reads logs or traces. A read with no tenant is refused as forbidden instead of reading every org. This covers list, time series, scalar and trace requests and both span reads of the trace operator. Co-authored-by: Hanzo Dev <[email protected]>
1965 lines
68 KiB
Go
1965 lines
68 KiB
Go
package rules
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
dsmock "github.com/hanzo-ds/mock"
|
|
|
|
"github.com/hanzoai/o11y/pkg/instrumentation/instrumentationtest"
|
|
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
|
"github.com/hanzoai/o11y/pkg/telemetrystore/telemetrystoretest"
|
|
"github.com/hanzoai/o11y/pkg/types/metrictypes"
|
|
qbtypes "github.com/hanzoai/o11y/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/hanzoai/o11y/pkg/types/ruletypes"
|
|
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
)
|
|
|
|
func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Eval test without recovery target",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range tcThresholdRuleEvalNoRecoveryTarget {
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: &c.target,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
assert.NoError(t, err)
|
|
|
|
values := c.values
|
|
for i := range values.Values {
|
|
values.Values[i].Timestamp = time.Now().UnixMilli()
|
|
}
|
|
|
|
resultVectors, err := rule.Threshold.Eval(c.values, rule.Unit(), ruletypes.EvalData{
|
|
ActiveAlerts: map[uint64]struct{}{},
|
|
})
|
|
assert.NoError(t, err, "Test case %d", idx)
|
|
|
|
// Compare result vectors with expected behavior
|
|
if c.expectAlert {
|
|
assert.NotEmpty(t, resultVectors, "Expected alert but got no result vectors for case %d", idx)
|
|
if len(resultVectors) > 0 {
|
|
found := false
|
|
for _, sample := range resultVectors {
|
|
if sample.V == c.expectedAlertSample.Value {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Expected alert sample value %.2f not found in result vectors for case %d. Got values: %v", c.expectedAlertSample.Value, idx, getVectorValues(resultVectors))
|
|
}
|
|
} else {
|
|
assert.Empty(t, resultVectors, "Expected no alert but got result vectors for case %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLabelName(t *testing.T) {
|
|
cases := []struct {
|
|
labelName string
|
|
expected string
|
|
}{
|
|
{
|
|
labelName: "label",
|
|
expected: "label",
|
|
},
|
|
{
|
|
labelName: "label.with.dots",
|
|
expected: "label_with_dots",
|
|
},
|
|
{
|
|
labelName: "label-with-dashes",
|
|
expected: "label_with_dashes",
|
|
},
|
|
{
|
|
labelName: "labelwithnospaces",
|
|
expected: "labelwithnospaces",
|
|
},
|
|
{
|
|
labelName: "label with spaces",
|
|
expected: "label_with_spaces",
|
|
},
|
|
{
|
|
labelName: "label with spaces and .dots",
|
|
expected: "label_with_spaces_and__dots",
|
|
},
|
|
{
|
|
labelName: "label with spaces and -dashes",
|
|
expected: "label_with_spaces_and__dashes",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.expected, ruletypes.NormalizeLabelName(c.labelName))
|
|
}
|
|
}
|
|
|
|
func TestPrepareParamsForLogs(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Tricky Condition Tests",
|
|
AlertType: ruletypes.AlertTypeLogs,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.LogAggregation{
|
|
{
|
|
Expression: "count",
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalLogs,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CompareOperator: ruletypes.ValueIsNotEq,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
Target: &[]float64{0.0}[0],
|
|
SelectedQuery: "A",
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: postableRule.RuleCondition.Target,
|
|
MatchType: postableRule.RuleCondition.MatchType,
|
|
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
|
},
|
|
},
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
assert.NoError(t, err)
|
|
|
|
ts := time.UnixMilli(1705469040000)
|
|
|
|
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
|
|
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000%2C%22end%22%3A1705468920000%2C%22pageSize%22%3A100%7D")
|
|
assert.Contains(t, params, "&startTime=1705468620000")
|
|
assert.Contains(t, params, "&endTime=1705468920000")
|
|
}
|
|
|
|
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Tricky Condition Tests",
|
|
AlertType: ruletypes.AlertTypeLogs,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 1 * time.Minute},
|
|
Aggregations: []qbtypes.LogAggregation{
|
|
{
|
|
Expression: "count()",
|
|
},
|
|
},
|
|
Filter: &qbtypes.Filter{
|
|
Expression: "service.name EXISTS",
|
|
},
|
|
Signal: telemetrytypes.SignalLogs,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CompareOperator: ruletypes.ValueIsNotEq,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
Target: &[]float64{0.0}[0],
|
|
SelectedQuery: "A",
|
|
},
|
|
Version: "v5",
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: postableRule.RuleCondition.Target,
|
|
MatchType: postableRule.RuleCondition.MatchType,
|
|
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
|
},
|
|
},
|
|
}
|
|
externalURL := mustParseURL(t, "http://localhost:8000")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
assert.NoError(t, err)
|
|
|
|
ts := time.UnixMilli(1753527163000)
|
|
|
|
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
|
|
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522logs%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000&timeRange=%7B%22start%22%3A1753526700000%2C%22end%22%3A1753527000000%2C%22pageSize%22%3A100%7D")
|
|
}
|
|
|
|
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Tricky Condition Tests",
|
|
AlertType: ruletypes.AlertTypeTraces,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 1 * time.Minute},
|
|
Aggregations: []qbtypes.TraceAggregation{
|
|
{
|
|
Expression: "count()",
|
|
},
|
|
},
|
|
Filter: &qbtypes.Filter{
|
|
Expression: "service.name EXISTS",
|
|
},
|
|
Signal: telemetrytypes.SignalTraces,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CompareOperator: ruletypes.ValueIsNotEq,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
Target: &[]float64{0.0}[0],
|
|
SelectedQuery: "A",
|
|
},
|
|
Version: "v5",
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: postableRule.RuleCondition.Target,
|
|
MatchType: postableRule.RuleCondition.MatchType,
|
|
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
|
},
|
|
},
|
|
}
|
|
externalURL := mustParseURL(t, "http://localhost:8000")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
assert.NoError(t, err)
|
|
|
|
ts := time.UnixMilli(1753527163000)
|
|
|
|
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
|
|
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522traces%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000000000&timeRange=%7B%22start%22%3A1753526700000000000%2C%22end%22%3A1753527000000000000%2C%22pageSize%22%3A100%7D")
|
|
}
|
|
|
|
func TestPrepareParamsForTraces(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Links to traces test",
|
|
AlertType: ruletypes.AlertTypeTraces,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.TraceAggregation{
|
|
{
|
|
Expression: "avg(duration_nano)",
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalTraces,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CompareOperator: ruletypes.ValueIsNotEq,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
Target: &[]float64{0.0}[0],
|
|
SelectedQuery: "A",
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: postableRule.RuleCondition.Target,
|
|
MatchType: postableRule.RuleCondition.MatchType,
|
|
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
|
},
|
|
},
|
|
}
|
|
externalURL := mustParseURL(t, "http://localhost:8000")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
ts := time.UnixMilli(1705469040000)
|
|
|
|
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
|
|
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000000000%2C%22end%22%3A1705468920000000000%2C%22pageSize%22%3A100%7D")
|
|
assert.Contains(t, params, "&startTime=1705468620000000000")
|
|
assert.Contains(t, params, "&endTime=1705468920000000000")
|
|
}
|
|
|
|
func TestThresholdRuleLabelNormalization(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Tricky Condition Tests",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
values *qbtypes.TimeSeries
|
|
expectAlert bool
|
|
compareOperator ruletypes.CompareOperator
|
|
matchType ruletypes.MatchType
|
|
target float64
|
|
}{
|
|
// Test cases for Equals Always
|
|
{
|
|
values: &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Value: 0.0},
|
|
{Value: 0.0},
|
|
{Value: 0.0},
|
|
{Value: 0.0},
|
|
{Value: 0.0},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
},
|
|
expectAlert: true,
|
|
compareOperator: ruletypes.ValueIsEq,
|
|
matchType: ruletypes.AllTheTimes,
|
|
target: 0.0,
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range cases {
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
TargetValue: &c.target,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8000")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
assert.NoError(t, err)
|
|
|
|
values := c.values
|
|
for i := range values.Values {
|
|
values.Values[i].Timestamp = time.Now().UnixMilli()
|
|
}
|
|
|
|
vector, err := rule.Threshold.Eval(c.values, rule.Unit(), ruletypes.EvalData{})
|
|
assert.NoError(t, err)
|
|
|
|
for _, lbl := range c.values.Labels {
|
|
for _, sample := range vector {
|
|
assert.Equal(t, lbl.Value, sample.Metric.Get(lbl.Key.Name))
|
|
}
|
|
}
|
|
|
|
// Get result vectors from threshold evaluation
|
|
resultVectors, err := rule.Threshold.Eval(c.values, rule.Unit(), ruletypes.EvalData{})
|
|
assert.NoError(t, err, "Test case %d", idx)
|
|
|
|
// Compare result vectors with expected behavior
|
|
if c.expectAlert {
|
|
assert.NotEmpty(t, resultVectors, "Expected alert but got no result vectors for case %d", idx)
|
|
// For this test, we don't have expectedAlertSample, so just verify vectors exist
|
|
} else {
|
|
assert.Empty(t, resultVectors, "Expected no alert but got result vectors for case %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThresholdRuleUnitCombinations(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Units test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "o11y_calls_total",
|
|
Temporality: metrictypes.Cumulative,
|
|
TimeAggregation: metrictypes.TimeAggregationRate,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
SelectedQuery: "A",
|
|
},
|
|
}
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
|
mockMetadataStore.TypeMap["o11y_calls_total"] = metrictypes.SumType
|
|
|
|
cols := []dsmock.ColumnType{
|
|
{Name: "ts", Type: "DateTime"},
|
|
{Name: "value", Type: "Float64"},
|
|
}
|
|
|
|
cases := []struct {
|
|
targetUnit string
|
|
yAxisUnit string
|
|
values [][]any
|
|
expectAlerts int
|
|
compareOperator ruletypes.CompareOperator
|
|
matchType ruletypes.MatchType
|
|
target float64
|
|
summaryAny []string
|
|
}{
|
|
{
|
|
targetUnit: "s",
|
|
yAxisUnit: "ns",
|
|
values: [][]any{
|
|
{time.Now(), float64(572588400)}, // 0.57 seconds
|
|
{time.Now().Add(1 * time.Second), float64(572386400)}, // 0.57 seconds
|
|
{time.Now().Add(2 * time.Second), float64(300947400)}, // 0.3 seconds
|
|
{time.Now().Add(3 * time.Second), float64(299316000)}, // 0.3 seconds
|
|
{time.Now().Add(4 * time.Second), float64(66640400.00000001)}, // 0.06 seconds
|
|
},
|
|
expectAlerts: 0,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 1, // 1 second
|
|
},
|
|
{
|
|
targetUnit: "ms",
|
|
yAxisUnit: "ns",
|
|
values: [][]any{
|
|
{time.Now(), float64(572588400)}, // 572.58 ms
|
|
{time.Now().Add(1 * time.Second), float64(572386400)}, // 572.38 ms
|
|
{time.Now().Add(2 * time.Second), float64(300947400)}, // 300.94 ms
|
|
{time.Now().Add(3 * time.Second), float64(299316000)}, // 299.31 ms
|
|
{time.Now().Add(4 * time.Second), float64(66640400.00000001)}, // 66.64 ms
|
|
},
|
|
expectAlerts: 1,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 200, // 200 ms
|
|
summaryAny: []string{
|
|
"the observed metric value is 573 ms",
|
|
},
|
|
},
|
|
{
|
|
targetUnit: "decgbytes",
|
|
yAxisUnit: "bytes",
|
|
values: [][]any{
|
|
{time.Now(), float64(2863284053)}, // 2.86 GB
|
|
{time.Now().Add(1 * time.Second), float64(2863388842)}, // 2.86 GB
|
|
{time.Now().Add(2 * time.Second), float64(300947400)}, // 0.3 GB
|
|
{time.Now().Add(3 * time.Second), float64(299316000)}, // 0.3 GB
|
|
{time.Now().Add(4 * time.Second), float64(66640400.00000001)}, // 66.64 MB
|
|
},
|
|
expectAlerts: 0,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 200, // 200 GB
|
|
},
|
|
{
|
|
targetUnit: "decgbytes",
|
|
yAxisUnit: "By",
|
|
values: [][]any{
|
|
{time.Now(), float64(2863284053)}, // 2.86 GB
|
|
{time.Now().Add(1 * time.Second), float64(2863388842)}, // 2.86 GB
|
|
{time.Now().Add(2 * time.Second), float64(300947400)}, // 0.3 GB
|
|
{time.Now().Add(3 * time.Second), float64(299316000)}, // 0.3 GB
|
|
{time.Now().Add(4 * time.Second), float64(66640400.00000001)}, // 66.64 MB
|
|
},
|
|
expectAlerts: 0,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 200, // 200 GB
|
|
},
|
|
{
|
|
targetUnit: "h",
|
|
yAxisUnit: "min",
|
|
values: [][]any{
|
|
{time.Now(), float64(55)}, // 55 minutes
|
|
{time.Now().Add(1 * time.Minute), float64(57)}, // 57 minutes
|
|
{time.Now().Add(2 * time.Minute), float64(30)}, // 30 minutes
|
|
{time.Now().Add(3 * time.Minute), float64(29)}, // 29 minutes
|
|
},
|
|
expectAlerts: 0,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 1, // 1 hour
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range cases {
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
// We are testing the eval logic after the query is run
|
|
// so we don't care about the query string here
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
|
postableRule.RuleCondition.MatchType = c.matchType
|
|
postableRule.RuleCondition.Target = &c.target
|
|
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
|
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: postableRule.AlertName,
|
|
TargetValue: &c.target,
|
|
TargetUnit: c.targetUnit,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
postableRule.Annotations = map[string]string{
|
|
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
|
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8000")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
|
if c.expectAlerts != 0 {
|
|
foundCount := 0
|
|
for _, item := range rule.Active {
|
|
for _, summary := range c.summaryAny {
|
|
if strings.Contains(item.Annotations.Get("summary"), summary) {
|
|
foundCount++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThresholdRuleNoData(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "No data test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "o11y_calls_total",
|
|
Temporality: metrictypes.Delta,
|
|
TimeAggregation: metrictypes.TimeAggregationRate,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
}},
|
|
},
|
|
},
|
|
AlertOnAbsent: true,
|
|
},
|
|
}
|
|
|
|
cols := make([]dsmock.ColumnType, 0)
|
|
cols = append(cols, dsmock.ColumnType{Name: "value", Type: "Float64"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "attr", Type: "String"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "timestamp", Type: "DateTime"})
|
|
|
|
cases := []struct {
|
|
values [][]any
|
|
expectNoData bool
|
|
}{
|
|
{
|
|
values: [][]any{},
|
|
expectNoData: true,
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range cases {
|
|
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
|
|
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
|
mockMetadataStore.TypeMap["o11y_calls_total"] = metrictypes.SumType
|
|
|
|
var target float64 = 0
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: postableRule.AlertName,
|
|
TargetValue: &target,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
CompareOperator: ruletypes.ValueIsEq,
|
|
},
|
|
},
|
|
}
|
|
postableRule.Annotations = map[string]string{
|
|
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
|
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
|
|
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, 1, alertsFound, "case %d", idx)
|
|
for _, item := range rule.Active {
|
|
if c.expectNoData {
|
|
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
|
} else {
|
|
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThresholdRuleTracesLink(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Traces link test",
|
|
AlertType: ruletypes.AlertTypeTraces,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.TraceAggregation{{
|
|
Expression: "p95(duration_nano)",
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalTraces,
|
|
Filter: &qbtypes.Filter{
|
|
Expression: "http.method = 'GET'",
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
cols := make([]dsmock.ColumnType, 0)
|
|
cols = append(cols, dsmock.ColumnType{Name: "value", Type: "Float64"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "attr", Type: "String"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "timestamp", Type: "DateTime"})
|
|
|
|
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"http.method": {
|
|
{
|
|
Name: "http.method",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range testCases {
|
|
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
|
|
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
|
|
|
|
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
|
postableRule.RuleCondition.MatchType = c.matchType
|
|
postableRule.RuleCondition.Target = &c.target
|
|
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
|
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: postableRule.AlertName,
|
|
TargetValue: &c.target,
|
|
TargetUnit: c.targetUnit,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
postableRule.Annotations = map[string]string{
|
|
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
|
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
orgID := valuer.GenerateUUID()
|
|
rule, err := NewThresholdRule("69", orgID, &postableRule, querier, logger, externalURL, WithSQLStore(orgNameStore(t, orgID, "acme")))
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
if c.expectAlerts == 0 {
|
|
assert.Equal(t, 0, alertsFound, "case %d", idx)
|
|
} else {
|
|
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
|
for _, item := range rule.Active {
|
|
for name, value := range item.Annotations.Map() {
|
|
if name == ruletypes.AnnotationRelatedTraces {
|
|
assert.NotEmpty(t, value, "case %d", idx)
|
|
assert.Contains(t, value, "GET")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThresholdRuleLogsLink(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Logs link test",
|
|
AlertType: ruletypes.AlertTypeLogs,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.LogAggregation{
|
|
{
|
|
Expression: "count_distinct(component)",
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalLogs,
|
|
Filter: &qbtypes.Filter{
|
|
Expression: "k8s.container.name = 'testcontainer'",
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"k8s.container.name": {
|
|
{
|
|
Name: "k8s.container.name",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
"component": {
|
|
{
|
|
Name: "component",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
}
|
|
|
|
cols := make([]dsmock.ColumnType, 0)
|
|
cols = append(cols, dsmock.ColumnType{Name: "value", Type: "Float64"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "attr", Type: "String"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "timestamp", Type: "DateTime"})
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range testCases {
|
|
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
|
|
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
|
|
|
|
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
|
postableRule.RuleCondition.MatchType = c.matchType
|
|
postableRule.RuleCondition.Target = &c.target
|
|
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
|
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: postableRule.AlertName,
|
|
TargetValue: &c.target,
|
|
TargetUnit: c.targetUnit,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
postableRule.Annotations = map[string]string{
|
|
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
|
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
orgID := valuer.GenerateUUID()
|
|
rule, err := NewThresholdRule("69", orgID, &postableRule, querier, logger, externalURL, WithSQLStore(orgNameStore(t, orgID, "acme")))
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
if c.expectAlerts == 0 {
|
|
assert.Equal(t, 0, alertsFound, "case %d", idx)
|
|
} else {
|
|
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
|
for _, item := range rule.Active {
|
|
for name, value := range item.Annotations.Map() {
|
|
if name == ruletypes.AnnotationRelatedLogs {
|
|
assert.NotEmpty(t, value, "case %d", idx)
|
|
assert.Contains(t, value, "testcontainer")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultipleThresholdRule(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Multiple threshold test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "o11y_calls_total",
|
|
Temporality: metrictypes.Delta,
|
|
TimeAggregation: metrictypes.TimeAggregationRate,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
cols := make([]dsmock.ColumnType, 0)
|
|
cols = append(cols, dsmock.ColumnType{Name: "value", Type: "Float64"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "attr", Type: "String"})
|
|
cols = append(cols, dsmock.ColumnType{Name: "timestamp", Type: "DateTime"})
|
|
|
|
cases := []struct {
|
|
targetUnit string
|
|
yAxisUnit string
|
|
values [][]any
|
|
expectAlerts int
|
|
compareOperator ruletypes.CompareOperator
|
|
matchType ruletypes.MatchType
|
|
target float64
|
|
secondTarget float64
|
|
summaryAny []string
|
|
}{
|
|
{
|
|
targetUnit: "s",
|
|
yAxisUnit: "ns",
|
|
values: [][]any{
|
|
{float64(572588400), "attr", time.Now()}, // 0.57 seconds
|
|
{float64(572386400), "attr", time.Now().Add(1 * time.Second)}, // 0.57 seconds
|
|
{float64(300947400), "attr", time.Now().Add(2 * time.Second)}, // 0.3 seconds
|
|
{float64(299316000), "attr", time.Now().Add(3 * time.Second)}, // 0.3 seconds
|
|
{float64(66640400.00000001), "attr", time.Now().Add(4 * time.Second)}, // 0.06 seconds
|
|
},
|
|
expectAlerts: 1,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 1, // 1 second
|
|
secondTarget: .5,
|
|
summaryAny: []string{
|
|
"observed metric value is 573 ms",
|
|
},
|
|
},
|
|
{
|
|
targetUnit: "ms",
|
|
yAxisUnit: "ns",
|
|
values: [][]any{
|
|
{float64(572588400), "attr", time.Now()}, // 572.58 ms
|
|
{float64(572386400), "attr", time.Now().Add(1 * time.Second)}, // 572.38 ms
|
|
{float64(300947400), "attr", time.Now().Add(2 * time.Second)}, // 300.94 ms
|
|
{float64(299316000), "attr", time.Now().Add(3 * time.Second)}, // 299.31 ms
|
|
{float64(66640400.00000001), "attr", time.Now().Add(4 * time.Second)}, // 66.64 ms
|
|
},
|
|
expectAlerts: 2, // Expects 1 values exceed 200ms (573) + 1 values exceed 500ms (5723)
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 200, // 200 ms
|
|
secondTarget: 500,
|
|
summaryAny: []string{
|
|
"the observed metric value is 573 ms",
|
|
"the observed metric value is 573 ms",
|
|
},
|
|
},
|
|
{
|
|
targetUnit: "decgbytes",
|
|
yAxisUnit: "bytes",
|
|
values: [][]any{
|
|
{float64(2863284053), "attr", time.Now()}, // 2.86 GB
|
|
{float64(2863388842), "attr", time.Now().Add(1 * time.Second)}, // 2.86 GB
|
|
{float64(300947400), "attr", time.Now().Add(2 * time.Second)}, // 0.3 GB
|
|
{float64(299316000), "attr", time.Now().Add(3 * time.Second)}, // 0.3 GB
|
|
{float64(66640400.00000001), "attr", time.Now().Add(4 * time.Second)}, // 66.64 MB
|
|
},
|
|
expectAlerts: 1,
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: 200, // 200 GB
|
|
secondTarget: 2, // 2GB
|
|
summaryAny: []string{
|
|
"observed metric value is 2.7 GiB",
|
|
},
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
|
|
for idx, c := range cases {
|
|
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
|
|
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
|
mockMetadataStore.TypeMap["o11y_calls_total"] = metrictypes.SumType
|
|
|
|
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
|
postableRule.RuleCondition.MatchType = c.matchType
|
|
postableRule.RuleCondition.Target = &c.target
|
|
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
|
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: "first_threshold",
|
|
TargetValue: &c.target,
|
|
TargetUnit: c.targetUnit,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
{
|
|
Name: "second_threshold",
|
|
TargetValue: &c.secondTarget,
|
|
TargetUnit: c.targetUnit,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
},
|
|
},
|
|
}
|
|
postableRule.Annotations = map[string]string{
|
|
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
|
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
|
|
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
|
if c.expectAlerts != 0 {
|
|
foundCount := 0
|
|
for _, item := range rule.Active {
|
|
for _, summary := range c.summaryAny {
|
|
if strings.Contains(item.Annotations.Get("summary"), summary) {
|
|
foundCount++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThresholdRuleEval_BasicCases(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Eval Recovery Threshold Test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
runEvalTests(t, postableRule, tcThresholdRuleEval)
|
|
}
|
|
|
|
func TestThresholdRuleEval_MatchPlusCompareOps(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Eval Match Plus Compare Ops Threshold Test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
runEvalTests(t, postableRule, tcThresholdRuleEvalMatchPlusCompareOps)
|
|
}
|
|
|
|
// TestThresholdRuleEval_SendUnmatchedBypassesRecovery tests the case where the sendUnmatched is true and the recovery target is met.
|
|
// In this case, the rule should return the first sample as sendUnmatched is supposed to be used in tests and in case of tests
|
|
// recovery target is expected to be present. This test make sure this behavior is working as expected.
|
|
func TestThresholdRuleEval_SendUnmatchedBypassesRecovery(t *testing.T) {
|
|
target := 10.0
|
|
recovery := 4.0
|
|
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Send unmatched bypass recovery",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: "primary",
|
|
TargetValue: &target,
|
|
RecoveryTarget: &recovery,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
CompareOperator: ruletypes.ValueIsAbove,
|
|
},
|
|
},
|
|
}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
series := &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Timestamp: now.UnixMilli(), Value: 3},
|
|
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
|
|
{Timestamp: now.Add(2 * time.Minute).UnixMilli(), Value: 5},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
}
|
|
|
|
alertLabels := ruletypes.PrepareSampleLabelsForRule(series.Labels, "primary")
|
|
activeAlerts := map[uint64]struct{}{alertLabels.Hash(): {}}
|
|
|
|
resultVectors, err := rule.Threshold.Eval(series, rule.Unit(), ruletypes.EvalData{
|
|
ActiveAlerts: activeAlerts,
|
|
SendUnmatched: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resultVectors, 1, "expected unmatched sample to be returned")
|
|
|
|
smpl := resultVectors[0]
|
|
assert.Equal(t, float64(3), smpl.V)
|
|
assert.False(t, smpl.IsRecovering, "unmatched path should not mark sample as recovering")
|
|
assert.Equal(t, float64(4), *smpl.RecoveryTarget, "unmatched path should set recovery target")
|
|
assert.InDelta(t, target, smpl.Target, 0.01)
|
|
assert.Equal(t, "primary", smpl.Metric.Get(ruletypes.LabelThresholdName))
|
|
}
|
|
|
|
func intPtr(v int) *int {
|
|
return &v
|
|
}
|
|
|
|
// TestThresholdRuleEval_SendUnmatchedVariants tests the different variants of sendUnmatched behavior.
|
|
// It tests the case where sendUnmatched is true, false.
|
|
func TestThresholdRuleEval_SendUnmatchedVariants(t *testing.T) {
|
|
target := 10.0
|
|
recovery := 5.0
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Send unmatched variants",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
tests := []recoveryTestCase{
|
|
{
|
|
description: "sendUnmatched returns first valid point",
|
|
values: &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Timestamp: now.UnixMilli(), Value: 3},
|
|
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
},
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: target,
|
|
recoveryTarget: &recovery,
|
|
thresholdName: "primary",
|
|
// Since sendUnmatched is true, the rule should return the first valid point
|
|
// even if it doesn't match the rule condition with current target value of 10.0
|
|
sendUnmatched: true,
|
|
expectSamples: intPtr(1),
|
|
expectedSampleValue: 3,
|
|
},
|
|
{
|
|
description: "sendUnmatched false suppresses unmatched",
|
|
values: &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Timestamp: now.UnixMilli(), Value: 3},
|
|
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
},
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: target,
|
|
recoveryTarget: &recovery,
|
|
thresholdName: "primary",
|
|
// Since sendUnmatched is false, the rule should not return any samples
|
|
sendUnmatched: false,
|
|
expectSamples: intPtr(0),
|
|
},
|
|
{
|
|
description: "sendUnmatched skips NaN and uses next point",
|
|
values: &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Timestamp: now.UnixMilli(), Value: math.NaN()},
|
|
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: math.Inf(1)},
|
|
{Timestamp: now.Add(2 * time.Minute).UnixMilli(), Value: 7},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
},
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: target,
|
|
recoveryTarget: &recovery,
|
|
thresholdName: "primary",
|
|
// Since sendUnmatched is true, the rule should return the first valid point
|
|
// even if it doesn't match the rule condition with current target value of 10.0
|
|
sendUnmatched: true,
|
|
expectSamples: intPtr(1),
|
|
expectedSampleValue: 7,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
runEvalTests(t, postableRule, []recoveryTestCase{tc})
|
|
}
|
|
}
|
|
|
|
// TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse tests the case where the recovery target is not met and sendUnmatched is false.
|
|
// In this case, the rule should not return any samples as no alert is active plus the recovery target is not met.
|
|
func TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse(t *testing.T) {
|
|
target := 10.0
|
|
recovery := 5.0
|
|
|
|
now := time.Now()
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Recovery not met sendUnmatched false",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
tc := recoveryTestCase{
|
|
description: "recovery target present but not met, sendUnmatched false",
|
|
values: &qbtypes.TimeSeries{
|
|
Values: []*qbtypes.TimeSeriesValue{
|
|
{Timestamp: now.UnixMilli(), Value: 3},
|
|
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
|
|
},
|
|
Labels: []*qbtypes.Label{
|
|
{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "frontend"},
|
|
},
|
|
},
|
|
compareOperator: ruletypes.ValueIsAbove,
|
|
matchType: ruletypes.AtleastOnce,
|
|
target: target,
|
|
recoveryTarget: &recovery,
|
|
thresholdName: "primary",
|
|
sendUnmatched: false,
|
|
expectSamples: intPtr(0),
|
|
activeAlerts: nil, // will auto-calc
|
|
expectedTarget: target,
|
|
expectedRecoveryTarget: recovery,
|
|
}
|
|
|
|
runEvalTests(t, postableRule, []recoveryTestCase{tc})
|
|
}
|
|
|
|
func runEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases []recoveryTestCase) {
|
|
logger := instrumentationtest.New().Logger()
|
|
for _, c := range testCases {
|
|
t.Run(c.description, func(t *testing.T) {
|
|
// Prepare threshold with recovery target
|
|
threshold := ruletypes.BasicRuleThreshold{
|
|
Name: c.thresholdName,
|
|
TargetValue: &c.target,
|
|
RecoveryTarget: c.recoveryTarget,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
}
|
|
|
|
// Build thresholds list
|
|
thresholds := ruletypes.BasicRuleThresholds{threshold}
|
|
|
|
// Add additional thresholds if specified
|
|
for _, addThreshold := range c.additionalThresholds {
|
|
thresholds = append(thresholds, ruletypes.BasicRuleThreshold{
|
|
Name: addThreshold.name,
|
|
TargetValue: &addThreshold.target,
|
|
RecoveryTarget: addThreshold.recoveryTarget,
|
|
MatchType: addThreshold.matchType,
|
|
CompareOperator: addThreshold.compareOperator,
|
|
})
|
|
}
|
|
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: thresholds,
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
return
|
|
}
|
|
|
|
values := c.values
|
|
for i := range values.Values {
|
|
values.Values[i].Timestamp = time.Now().UnixMilli()
|
|
}
|
|
|
|
// Prepare activeAlerts: if nil, auto-calculate from labels + thresholdName
|
|
activeAlerts := c.activeAlerts
|
|
if activeAlerts == nil {
|
|
sampleLabels := ruletypes.PrepareSampleLabelsForRule(values.Labels, c.thresholdName)
|
|
alertHash := sampleLabels.Hash()
|
|
activeAlerts = map[uint64]struct{}{alertHash: {}}
|
|
// Handle other thresholds
|
|
for _, addThreshold := range c.additionalThresholds {
|
|
sampleLabels := ruletypes.PrepareSampleLabelsForRule(values.Labels, addThreshold.name)
|
|
alertHash := sampleLabels.Hash()
|
|
activeAlerts[alertHash] = struct{}{}
|
|
}
|
|
}
|
|
|
|
evalData := ruletypes.EvalData{
|
|
ActiveAlerts: activeAlerts,
|
|
SendUnmatched: c.sendUnmatched,
|
|
}
|
|
|
|
resultVectors, err := rule.Threshold.Eval(values, rule.Unit(), evalData)
|
|
assert.NoError(t, err)
|
|
|
|
if c.expectSamples != nil {
|
|
assert.Equal(t, *c.expectSamples, len(resultVectors), "sample count mismatch")
|
|
if *c.expectSamples > 0 {
|
|
assert.InDelta(t, c.expectedSampleValue, resultVectors[0].V, 0.01, "sample value mismatch")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Verify results
|
|
if c.expectAlert || c.expectRecovery {
|
|
// Either a new alert fires or recovery happens - both return result vectors
|
|
assert.NotEmpty(t, resultVectors, "Expected alert or recovery but got no result vectors")
|
|
if len(resultVectors) > 0 {
|
|
found := false
|
|
for _, sample := range resultVectors {
|
|
// Check if this is the expected sample
|
|
if sample.V == c.expectedAlertSample.Value {
|
|
found = true
|
|
// Verify IsRecovering flag
|
|
assert.Equal(t, c.expectRecovery, sample.IsRecovering, "IsRecovering flag mismatch")
|
|
// Verify target value
|
|
if c.expectedTarget != 0 || sample.Target != 0 {
|
|
assert.InDelta(t, c.expectedTarget, sample.Target, 0.01, "Target value mismatch")
|
|
}
|
|
if sample.RecoveryTarget != nil {
|
|
assert.InDelta(t, *sample.RecoveryTarget, c.expectedRecoveryTarget, 0.01, "Recovery target value mismatch")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Expected alert sample value %.2f not found in result vectors. Got values: %v", c.expectedAlertSample.Value, getVectorValues(resultVectors))
|
|
}
|
|
} else {
|
|
// No alert and no recovery expected - should be empty
|
|
assert.Empty(t, resultVectors, "Expected no alert but got result vectors: %v", resultVectors)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// runMultiThresholdEvalTests runs tests for multiple threshold scenarios
|
|
// where each threshold can be in a different state (firing, recovering, resolved)
|
|
func runMultiThresholdEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases []multiThresholdTestCase) {
|
|
logger := instrumentationtest.New().Logger()
|
|
for _, c := range testCases {
|
|
t.Run(c.description, func(t *testing.T) {
|
|
// Prepare primary threshold
|
|
threshold := ruletypes.BasicRuleThreshold{
|
|
Name: c.thresholdName,
|
|
TargetValue: &c.target,
|
|
RecoveryTarget: c.recoveryTarget,
|
|
MatchType: c.matchType,
|
|
CompareOperator: c.compareOperator,
|
|
}
|
|
|
|
// Build thresholds list
|
|
thresholds := ruletypes.BasicRuleThresholds{threshold}
|
|
|
|
// Add additional thresholds
|
|
for _, addThreshold := range c.additionalThresholds {
|
|
thresholds = append(thresholds, ruletypes.BasicRuleThreshold{
|
|
Name: addThreshold.name,
|
|
TargetValue: &addThreshold.target,
|
|
RecoveryTarget: addThreshold.recoveryTarget,
|
|
MatchType: addThreshold.matchType,
|
|
CompareOperator: addThreshold.compareOperator,
|
|
})
|
|
}
|
|
|
|
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: thresholds,
|
|
}
|
|
|
|
externalURL := mustParseURL(t, "http://localhost:8080")
|
|
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
|
if err != nil {
|
|
assert.NoError(t, err)
|
|
return
|
|
}
|
|
|
|
values := c.values
|
|
for i := range values.Values {
|
|
values.Values[i].Timestamp = time.Now().UnixMilli()
|
|
}
|
|
|
|
// Prepare activeAlerts: if nil, auto-calculate from labels + all threshold names
|
|
activeAlerts := c.activeAlerts
|
|
if activeAlerts == nil {
|
|
activeAlerts = make(map[uint64]struct{})
|
|
// Add primary threshold
|
|
sampleLabels := ruletypes.PrepareSampleLabelsForRule(values.Labels, c.thresholdName)
|
|
alertHash := sampleLabels.Hash()
|
|
activeAlerts[alertHash] = struct{}{}
|
|
// Add additional thresholds
|
|
for _, addThreshold := range c.additionalThresholds {
|
|
sampleLabels := ruletypes.PrepareSampleLabelsForRule(values.Labels, addThreshold.name)
|
|
alertHash := sampleLabels.Hash()
|
|
activeAlerts[alertHash] = struct{}{}
|
|
}
|
|
}
|
|
|
|
evalData := ruletypes.EvalData{
|
|
ActiveAlerts: activeAlerts,
|
|
}
|
|
|
|
resultVectors, err := rule.Threshold.Eval(values, rule.Unit(), evalData)
|
|
assert.NoError(t, err)
|
|
|
|
// Validate total sample count
|
|
assert.Equal(t, c.ExpectedSampleCount, len(resultVectors),
|
|
"Expected %d samples but got %d. Sample values: %v",
|
|
c.ExpectedSampleCount, len(resultVectors), getVectorValues(resultVectors))
|
|
|
|
// Build a map of threshold name -> sample for easy lookup
|
|
samplesByThreshold := make(map[string]ruletypes.Sample)
|
|
for _, sample := range resultVectors {
|
|
thresholdName := sample.Metric.Get(ruletypes.LabelThresholdName)
|
|
samplesByThreshold[thresholdName] = sample
|
|
}
|
|
|
|
// Validate each threshold's expected result
|
|
for thresholdName, expectation := range c.ExpectedResults {
|
|
sample, found := samplesByThreshold[thresholdName]
|
|
|
|
if expectation.ShouldReturnSample {
|
|
assert.True(t, found, "Expected sample for threshold '%s' but not found in results", thresholdName)
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
// Validate IsRecovering flag
|
|
assert.Equal(t, expectation.IsRecovering, sample.IsRecovering,
|
|
"Threshold '%s': IsRecovering flag mismatch", thresholdName)
|
|
|
|
// Validate sample value
|
|
assert.InDelta(t, expectation.SampleValue, sample.V, 0.01,
|
|
"Threshold '%s': Sample value mismatch", thresholdName)
|
|
|
|
// Validate target value
|
|
assert.InDelta(t, expectation.TargetValue, sample.Target, 0.01,
|
|
"Threshold '%s': Target value mismatch", thresholdName)
|
|
|
|
// Validate recovery target value
|
|
if expectation.RecoveryValue != nil {
|
|
assert.NotNil(t, sample.RecoveryTarget,
|
|
"Threshold '%s': Expected RecoveryTarget to be set but it was nil", thresholdName)
|
|
if sample.RecoveryTarget != nil {
|
|
assert.InDelta(t, *expectation.RecoveryValue, *sample.RecoveryTarget, 0.01,
|
|
"Threshold '%s': RecoveryTarget value mismatch", thresholdName)
|
|
}
|
|
}
|
|
} else {
|
|
assert.False(t, found, "Expected NO sample for threshold '%s' but found one with value %.2f",
|
|
thresholdName, sample.V)
|
|
}
|
|
}
|
|
|
|
// Validate sample order if specified
|
|
if len(c.ExpectedSampleOrder) > 0 {
|
|
assert.Equal(t, len(c.ExpectedSampleOrder), len(resultVectors),
|
|
"Expected sample order length mismatch")
|
|
for i, expectedName := range c.ExpectedSampleOrder {
|
|
if i < len(resultVectors) {
|
|
actualName := resultVectors[i].Metric.Get(ruletypes.LabelThresholdName)
|
|
assert.Equal(t, expectedName, actualName,
|
|
"Sample order mismatch at index %d: expected '%s', got '%s'",
|
|
i, expectedName, actualName)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestThresholdRuleEval_MultiThreshold tests multiple threshold scenarios
|
|
// where each threshold can be in a different state (firing, recovering, resolved)
|
|
func TestThresholdRuleEval_MultiThreshold(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Multi-Threshold Recovery Test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "probe_success",
|
|
TimeAggregation: metrictypes.TimeAggregationLatest,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
runMultiThresholdEvalTests(t, postableRule, tcThresholdRuleEvalMultiThreshold)
|
|
}
|
|
|
|
func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
|
postableRule := ruletypes.PostableRule{
|
|
AlertName: "Unit test",
|
|
AlertType: ruletypes.AlertTypeMetric,
|
|
RuleType: ruletypes.RuleTypeThreshold,
|
|
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
|
EvalWindow: valuer.MustParseTextDuration("5m"),
|
|
Frequency: valuer.MustParseTextDuration("1m"),
|
|
}},
|
|
RuleCondition: &ruletypes.RuleCondition{
|
|
CompareOperator: ruletypes.ValueIsAbove,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: time.Minute},
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "o11y_calls_total",
|
|
Temporality: metrictypes.Delta,
|
|
TimeAggregation: metrictypes.TimeAggregationRate,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
description string
|
|
requireMinPoints bool
|
|
requiredNumPoints int
|
|
values [][]any
|
|
target float64
|
|
expectAlerts int
|
|
}{
|
|
{
|
|
description: "AlertCondition=false, RequireMinPoints=false",
|
|
requireMinPoints: false,
|
|
values: [][]any{
|
|
{100.0, "attr", time.Now()},
|
|
{150.0, "attr", time.Now().Add(-1 * time.Minute)},
|
|
},
|
|
target: 200,
|
|
expectAlerts: 0,
|
|
},
|
|
{
|
|
description: "AlertCondition=true, RequireMinPoints=false",
|
|
requireMinPoints: false,
|
|
values: [][]any{
|
|
{100.0, "attr", time.Now()},
|
|
{150.0, "attr", time.Now().Add(-1 * time.Minute)},
|
|
{250.0, "attr", time.Now().Add(-2 * time.Minute)},
|
|
},
|
|
target: 200,
|
|
expectAlerts: 1,
|
|
},
|
|
{
|
|
description: "AlertCondition=true, RequireMinPoints=true, NumPoints=more_than_required",
|
|
requireMinPoints: true,
|
|
requiredNumPoints: 2,
|
|
values: [][]any{
|
|
{100.0, "attr", time.Now()},
|
|
{150.0, "attr", time.Now().Add(-1 * time.Minute)},
|
|
{250.0, "attr", time.Now().Add(-2 * time.Minute)},
|
|
},
|
|
target: 200,
|
|
expectAlerts: 1,
|
|
},
|
|
{
|
|
description: "AlertCondition=true, RequireMinPoints=true, NumPoints=same_as_required",
|
|
requireMinPoints: true,
|
|
requiredNumPoints: 3,
|
|
values: [][]any{
|
|
{100.0, "attr", time.Now()},
|
|
{150.0, "attr", time.Now().Add(-1 * time.Minute)},
|
|
{250.0, "attr", time.Now().Add(-2 * time.Minute)},
|
|
},
|
|
target: 200,
|
|
expectAlerts: 1,
|
|
},
|
|
{
|
|
description: "AlertCondition=true, RequireMinPoints=true, NumPoints=insufficient",
|
|
requireMinPoints: true,
|
|
requiredNumPoints: 4,
|
|
values: [][]any{
|
|
{100.0, "attr", time.Now()},
|
|
{150.0, "attr", time.Now().Add(-1 * time.Minute)},
|
|
{250.0, "attr", time.Now().Add(-2 * time.Minute)},
|
|
},
|
|
target: 200,
|
|
expectAlerts: 0,
|
|
},
|
|
{
|
|
description: "AlertCondition=true, RequireMinPoints=true, NumPoints=zero",
|
|
requireMinPoints: true,
|
|
requiredNumPoints: 4,
|
|
values: [][]any{},
|
|
target: 200,
|
|
expectAlerts: 0,
|
|
},
|
|
}
|
|
|
|
cols := []dsmock.ColumnType{
|
|
{Name: "value", Type: "Float64"},
|
|
{Name: "key", Type: "String"},
|
|
{Name: "ts", Type: "DateTime"},
|
|
}
|
|
|
|
for idx, c := range cases {
|
|
logger := instrumentationtest.New().Logger()
|
|
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
|
|
|
rows := dsmock.NewRows(cols, c.values)
|
|
queryString := "SELECT any"
|
|
telemetryStore.Mock().
|
|
ExpectQuery(queryString).
|
|
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
|
WillReturnRows(rows)
|
|
|
|
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
|
mockMetadataStore.TypeMap["o11y_calls_total"] = metrictypes.SumType
|
|
|
|
rc := postableRule.RuleCondition
|
|
rc.Target = &c.target
|
|
rc.RequireMinPoints = c.requireMinPoints
|
|
rc.RequiredNumPoints = c.requiredNumPoints
|
|
rc.Thresholds = &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: postableRule.AlertName,
|
|
TargetValue: &c.target,
|
|
MatchType: rc.MatchType,
|
|
CompareOperator: rc.CompareOperator,
|
|
},
|
|
},
|
|
}
|
|
|
|
rule, err := NewThresholdRule(
|
|
valuer.GenerateUUID().StringValue(),
|
|
valuer.GenerateUUID(),
|
|
&postableRule,
|
|
querier,
|
|
logger,
|
|
mustParseURL(t, "http://localhost:8080"),
|
|
)
|
|
require.NoError(t, err)
|
|
t.Run(fmt.Sprintf("%d, %s", idx, c.description), func(t *testing.T) {
|
|
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
|
})
|
|
}
|
|
}
|