mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
817 lines
30 KiB
Go
817 lines
30 KiB
Go
package rules
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hanzoai/o11y/pkg/instrumentation/instrumentationtest"
|
|
"github.com/hanzoai/o11y/pkg/queryparser"
|
|
"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/types/telemetrytypes/telemetrytypestest"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
)
|
|
|
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
|
t.Helper()
|
|
u, err := url.Parse(raw)
|
|
require.NoError(t, err)
|
|
return u
|
|
}
|
|
|
|
// createTestSeries creates a *qbtypes.TimeSeries with the given labels and optional points
|
|
// so we don't exactly need the points in the series because the labels are used to determine if the series is new or old
|
|
// we use the labels to create a lookup key for the series and then check the first_seen timestamp for the series in the metadata table
|
|
func createTestSeries(kvMap map[string]string, values []*qbtypes.TimeSeriesValue) *qbtypes.TimeSeries {
|
|
if values == nil {
|
|
values = []*qbtypes.TimeSeriesValue{}
|
|
}
|
|
lbls := make([]*qbtypes.Label, 0)
|
|
for k, v := range kvMap {
|
|
lbls = append(lbls, &qbtypes.Label{
|
|
Key: telemetrytypes.TelemetryFieldKey{Name: k},
|
|
Value: v,
|
|
})
|
|
}
|
|
return &qbtypes.TimeSeries{
|
|
Labels: lbls,
|
|
Values: values,
|
|
}
|
|
}
|
|
|
|
// seriesEqual compares two v3.Series by their labels
|
|
// Returns true if the series have the same labels (order doesn't matter)
|
|
func seriesEqual(s1, s2 *qbtypes.TimeSeries) bool {
|
|
if s1 == nil && s2 == nil {
|
|
return true
|
|
}
|
|
if s1 == nil || s2 == nil {
|
|
return false
|
|
}
|
|
if len(s1.Labels) != len(s2.Labels) {
|
|
return false
|
|
}
|
|
for k, v := range s1.Labels {
|
|
if s2.Labels[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// calculateFirstSeen calculates first_seen timestamp based on evalTime, delay, and isOld flag
|
|
func calculateFirstSeen(evalTime time.Time, delay time.Duration, isOld bool) int64 {
|
|
if isOld {
|
|
// Old: evalTime - (2 * delay)
|
|
return evalTime.Add(-2 * delay).UnixMilli()
|
|
}
|
|
// New: evalTime - (delay / 2)
|
|
return evalTime.Add(-delay / 2).UnixMilli()
|
|
}
|
|
|
|
// createFirstSeenMap creates a first_seen map for a series with given attributes
|
|
// metricName: the metric name
|
|
// groupByFields: list of groupBy field names
|
|
// evalTime: evaluation time
|
|
// delay: newGroupEvalDelay
|
|
// isOld: whether the series is old (true) or new (false)
|
|
// attributeValues: values for each groupBy field in order
|
|
func createFirstSeenMap(metricName string, groupByFields []string, evalTime time.Time, delay time.Duration, isOld bool, attributeValues ...string) map[telemetrytypes.MetricMetadataLookupKey]int64 {
|
|
result := make(map[telemetrytypes.MetricMetadataLookupKey]int64)
|
|
firstSeen := calculateFirstSeen(evalTime, delay, isOld)
|
|
|
|
for i, field := range groupByFields {
|
|
if i < len(attributeValues) {
|
|
key := telemetrytypes.MetricMetadataLookupKey{
|
|
MetricName: metricName,
|
|
AttributeName: field,
|
|
AttributeValue: attributeValues[i],
|
|
}
|
|
result[key] = firstSeen
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// mergeFirstSeenMaps merges multiple first_seen maps into one
|
|
// When the same key exists in multiple maps, it keeps the lowest value
|
|
// which simulatest the behavior of the Datastore query
|
|
// finding the minimum first_seen timestamp across all groupBy attributes for a single series
|
|
func mergeFirstSeenMaps(maps ...map[telemetrytypes.MetricMetadataLookupKey]int64) map[telemetrytypes.MetricMetadataLookupKey]int64 {
|
|
result := make(map[telemetrytypes.MetricMetadataLookupKey]int64)
|
|
for _, m := range maps {
|
|
for k, v := range m {
|
|
if existingValue, exists := result[k]; exists {
|
|
// Keep the lowest value
|
|
if v < existingValue {
|
|
result[k] = v
|
|
}
|
|
} else {
|
|
result[k] = v
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// createPostableRule creates a PostableRule with the given CompositeQuery
|
|
func createPostableRule(compositeQuery *ruletypes.AlertCompositeQuery) ruletypes.PostableRule {
|
|
return ruletypes.PostableRule{
|
|
AlertName: "Test Rule",
|
|
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: compositeQuery,
|
|
Thresholds: &ruletypes.RuleThresholdData{
|
|
Kind: ruletypes.BasicThresholdKind,
|
|
Spec: ruletypes.BasicRuleThresholds{
|
|
{
|
|
Name: "test-threshold",
|
|
TargetValue: func() *float64 { v := 1.0; return &v }(),
|
|
CompareOperator: ruletypes.ValueIsAbove,
|
|
MatchType: ruletypes.AtleastOnce,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// filterNewSeriesTestCase represents a test case for FilterNewSeries
|
|
type filterNewSeriesTestCase struct {
|
|
name string
|
|
compositeQuery *ruletypes.AlertCompositeQuery
|
|
series []*qbtypes.TimeSeries
|
|
firstSeenMap map[telemetrytypes.MetricMetadataLookupKey]int64
|
|
newGroupEvalDelay valuer.TextDuration
|
|
evalTime time.Time
|
|
expectedFiltered []*qbtypes.TimeSeries // series that should be in the final filtered result (old enough)
|
|
expectError bool
|
|
}
|
|
|
|
func TestBaseRule_FilterNewSeries(t *testing.T) {
|
|
defaultEvalTime := time.Unix(1700000000, 0)
|
|
defaultNewGroupEvalDelay := valuer.MustParseTextDuration("2m")
|
|
defaultDelay := defaultNewGroupEvalDelay.Duration()
|
|
defaultGroupByFields := []string{"service_name", "env"}
|
|
|
|
logger := instrumentationtest.New().Logger()
|
|
settings := instrumentationtest.New().ToProviderSettings()
|
|
|
|
tests := []filterNewSeriesTestCase{
|
|
{
|
|
name: "mixed old and new series - Builder query",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-new", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-missing", "env": "stage"}, nil),
|
|
},
|
|
firstSeenMap: mergeFirstSeenMaps(
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc-old", "prod"),
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, false, "svc-new", "prod"),
|
|
// svc-missing has no metadata, so it will be included
|
|
),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-missing", "env": "stage"}, nil),
|
|
}, // svc-old and svc-missing should be included; svc-new is filtered out
|
|
},
|
|
{
|
|
name: "all new series - PromQL query",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypePromQL,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypePromQL,
|
|
Spec: qbtypes.PromQuery{
|
|
Name: "P1",
|
|
Query: "sum by (service_name,env) (rate(request_total[5m]))",
|
|
Disabled: false,
|
|
Step: qbtypes.Step{Duration: 0},
|
|
Stats: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-new1", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-new2", "env": "stage"}, nil),
|
|
},
|
|
firstSeenMap: mergeFirstSeenMaps(
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, false, "svc-new1", "prod"),
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, false, "svc-new2", "stage"),
|
|
),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{}, // all should be filtered out (new series)
|
|
},
|
|
{
|
|
name: "all old series - Datastore query",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeDatastoreSQL,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeDatastoreSQL,
|
|
Spec: qbtypes.DatastoreQuery{
|
|
Name: "CH1",
|
|
Query: "SELECT service_name, env FROM metrics WHERE metric_name='request_total' GROUP BY service_name, env",
|
|
Disabled: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old1", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-old2", "env": "stage"}, nil),
|
|
},
|
|
firstSeenMap: mergeFirstSeenMaps(
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc-old1", "prod"),
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc-old2", "stage"),
|
|
),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old1", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-old2", "env": "stage"}, nil),
|
|
}, // all should be included (old series)
|
|
},
|
|
{
|
|
name: "no grouping in query - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
}, // early return, no filtering - all series included
|
|
},
|
|
{
|
|
name: "no metric names - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
}, // early return, no filtering - all series included
|
|
},
|
|
{
|
|
name: "series with no matching labels - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"status": "200"}, nil), // no service_name or env
|
|
},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"status": "200"}, nil),
|
|
}, // series included as we can't decide if it's new or old
|
|
},
|
|
{
|
|
name: "series with missing metadata - PromQL",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypePromQL,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypePromQL,
|
|
Spec: qbtypes.PromQuery{
|
|
Name: "P1",
|
|
Query: "sum by (service_name,env) (rate(request_total[5m]))",
|
|
Disabled: false,
|
|
Step: qbtypes.Step{Duration: 0},
|
|
Stats: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-no-metadata", "env": "prod"}, nil),
|
|
},
|
|
firstSeenMap: createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc-old", "prod"),
|
|
// svc-no-metadata has no entry in firstSeenMap
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc-no-metadata", "env": "prod"}, nil),
|
|
}, // both should be included - svc-old is old, svc-no-metadata can't be decided
|
|
},
|
|
{
|
|
name: "series with partial metadata - Datastore",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeDatastoreSQL,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeDatastoreSQL,
|
|
Spec: qbtypes.DatastoreQuery{
|
|
Name: "CH1",
|
|
Query: "SELECT service_name, env FROM metrics WHERE metric_name='request_total' GROUP BY service_name, env",
|
|
Disabled: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-partial", "env": "prod"}, nil),
|
|
},
|
|
// Only provide metadata for service_name, not env
|
|
firstSeenMap: map[telemetrytypes.MetricMetadataLookupKey]int64{
|
|
{MetricName: "request_total", AttributeName: "service_name", AttributeValue: "svc-partial"}: calculateFirstSeen(defaultEvalTime, defaultDelay, true),
|
|
// env metadata is missing
|
|
},
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc-partial", "env": "prod"}, nil),
|
|
}, // has some metadata, uses max first_seen which is old
|
|
},
|
|
{
|
|
name: "empty series array - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{},
|
|
},
|
|
{
|
|
name: "zero delay - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
firstSeenMap: createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc1", "prod"),
|
|
newGroupEvalDelay: valuer.TextDuration{}, // zero delay
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
}, // with zero delay, all series pass
|
|
},
|
|
{
|
|
name: "multiple metrics with same groupBy keys - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
{
|
|
MetricName: "error_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
firstSeenMap: mergeFirstSeenMaps(
|
|
createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc1", "prod"),
|
|
createFirstSeenMap("error_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc1", "prod"),
|
|
),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
},
|
|
{
|
|
name: "series with multiple groupBy attributes where one is new and one is old - Builder",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalMetrics,
|
|
Aggregations: []qbtypes.MetricAggregation{
|
|
{
|
|
MetricName: "request_total",
|
|
TimeAggregation: metrictypes.TimeAggregationCount,
|
|
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "env"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
|
|
},
|
|
// service_name is old, env is new - should use max (new)
|
|
firstSeenMap: mergeFirstSeenMaps(
|
|
createFirstSeenMap("request_total", []string{"service_name"}, defaultEvalTime, defaultDelay, true, "svc1"),
|
|
createFirstSeenMap("request_total", []string{"env"}, defaultEvalTime, defaultDelay, false, "prod"),
|
|
),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{}, // max first_seen is new, so should be filtered out
|
|
},
|
|
{
|
|
name: "Logs query - should skip filtering and return empty skip indexes",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalLogs,
|
|
Aggregations: []qbtypes.LogAggregation{
|
|
{
|
|
Expression: "count()",
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
|
|
},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
|
|
}, // Logs queries should return early, no filtering - all included
|
|
},
|
|
{
|
|
name: "Traces query - should skip filtering and return empty skip indexes",
|
|
compositeQuery: &ruletypes.AlertCompositeQuery{
|
|
QueryType: ruletypes.QueryTypeBuilder,
|
|
Queries: []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
|
Name: "A",
|
|
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
|
Signal: telemetrytypes.SignalTraces,
|
|
Aggregations: []qbtypes.TraceAggregation{
|
|
{
|
|
Expression: "count()",
|
|
},
|
|
},
|
|
GroupBy: []qbtypes.GroupByKey{
|
|
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service_name"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
series: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
|
|
},
|
|
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
|
newGroupEvalDelay: defaultNewGroupEvalDelay,
|
|
evalTime: defaultEvalTime,
|
|
expectedFiltered: []*qbtypes.TimeSeries{
|
|
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
|
|
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
|
|
}, // Traces queries should return early, no filtering - all included
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create postableRule from compositeQuery
|
|
postableRule := createPostableRule(tt.compositeQuery)
|
|
|
|
// Setup mock metadata store
|
|
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
|
|
|
// Create query parser
|
|
queryParser := queryparser.New(settings)
|
|
|
|
// Use query parser to extract metric names and groupBy fields
|
|
analyzeResults, err := queryParser.AnalyzeQueryEnvelopes(context.Background(), tt.compositeQuery.Queries)
|
|
require.NoError(t, err)
|
|
|
|
// Aggregate results from all queries
|
|
metricNames := []string{}
|
|
groupedFields := []string{}
|
|
for _, result := range analyzeResults {
|
|
metricNames = append(metricNames, result.MetricNames...)
|
|
for _, col := range result.GroupByColumns {
|
|
groupedFields = append(groupedFields, col.OriginField)
|
|
}
|
|
}
|
|
|
|
// Setup metadata query mock
|
|
mockMetadataStore.SetFirstSeenFromMetricMetadata(tt.firstSeenMap)
|
|
|
|
postableRule.NotificationSettings = &ruletypes.NotificationSettings{
|
|
NewGroupEvalDelay: tt.newGroupEvalDelay,
|
|
}
|
|
|
|
// Create BaseRule using NewBaseRule
|
|
rule, err := NewBaseRule(
|
|
"test-rule",
|
|
valuer.GenerateUUID(),
|
|
&postableRule,
|
|
mustParseURL(t, "http://localhost:8080"),
|
|
WithQueryParser(queryParser),
|
|
WithLogger(logger),
|
|
WithMetadataStore(mockMetadataStore),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
filteredSeries, err := rule.FilterNewSeries(context.Background(), tt.evalTime, tt.series)
|
|
|
|
if tt.expectError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Build a map to count occurrences of each unique label combination in expected series
|
|
expectedCounts := make(map[string]int)
|
|
for _, expected := range tt.expectedFiltered {
|
|
key := labelsKey(expected.Labels)
|
|
expectedCounts[key]++
|
|
}
|
|
|
|
// Build a map to count occurrences of each unique label combination in filtered series
|
|
actualCounts := make(map[string]int)
|
|
for _, filtered := range filteredSeries {
|
|
key := labelsKey(filtered.Labels)
|
|
actualCounts[key]++
|
|
}
|
|
|
|
// Verify counts match for all expected label combinations
|
|
for key, expectedCount := range expectedCounts {
|
|
actualCount := actualCounts[key]
|
|
require.Equal(t, expectedCount, actualCount, "series with labels %s should appear %d times, but found %d times", key, expectedCount, actualCount)
|
|
}
|
|
|
|
// Verify no unexpected series were found (all actual series should be in expected)
|
|
require.Equal(t, len(tt.expectedFiltered), len(filteredSeries), "filtered series count should match expected")
|
|
for key := range actualCounts {
|
|
_, exists := expectedCounts[key]
|
|
require.True(t, exists, "unexpected series found with labels: %s", key)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseRule_ExternalURL(t *testing.T) {
|
|
|
|
tests := []struct {
|
|
name string
|
|
externalURL *url.URL
|
|
want string
|
|
}{
|
|
{name: "default value returned as-is", externalURL: mustParseURL(t, "http://localhost:8080"), want: "http://localhost:8080"},
|
|
{name: "configured https host", externalURL: mustParseURL(t, "https://o11y.example.com"), want: "https://o11y.example.com"},
|
|
{name: "configured host with port", externalURL: mustParseURL(t, "http://o11y.internal:3301"), want: "http://o11y.internal:3301"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p := createPostableRule(&ruletypes.AlertCompositeQuery{})
|
|
r, err := NewBaseRule("some-id", valuer.GenerateUUID(), &p, tc.externalURL)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
require.Equal(t, tc.want, r.ExternalURL("", nil))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseRule_GeneratorURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ruleID string
|
|
externalURL *url.URL
|
|
want string
|
|
}{
|
|
{
|
|
name: "configured external URL",
|
|
ruleID: "abc",
|
|
externalURL: mustParseURL(t, "https://o11y.example.com"),
|
|
want: "https://o11y.example.com/alerts/overview?ruleId=abc",
|
|
},
|
|
{
|
|
name: "default external URL is used as-is",
|
|
ruleID: "abc",
|
|
externalURL: mustParseURL(t, "http://localhost:8080"),
|
|
want: "http://localhost:8080/alerts/overview?ruleId=abc",
|
|
},
|
|
{
|
|
name: "external URL with base path is preserved",
|
|
ruleID: "abc",
|
|
externalURL: mustParseURL(t, "https://o11y.example.com/o11y"),
|
|
want: "https://o11y.example.com/o11y/alerts/overview?ruleId=abc",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p := createPostableRule(&ruletypes.AlertCompositeQuery{})
|
|
r, err := NewBaseRule(tc.ruleID, valuer.GenerateUUID(), &p, tc.externalURL)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
require.Equal(t, tc.want, r.GeneratorURL())
|
|
})
|
|
}
|
|
}
|
|
|
|
// labelsKey creates a deterministic string key from a labels map
|
|
// This is used to group series by their unique label combinations
|
|
func labelsKey(lbls []*qbtypes.Label) string {
|
|
if len(lbls) == 0 {
|
|
return ""
|
|
}
|
|
temp := ruletypes.NewBuilder()
|
|
for _, item := range lbls {
|
|
temp.Set(item.Key.Name, fmt.Sprint(item.Value))
|
|
}
|
|
return temp.Labels().String()
|
|
}
|