Compare commits

..

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
3e2b497cd7 fix: dashboard - textbox default variable not working 2025-12-22 10:45:50 +05:30
19 changed files with 63 additions and 3787 deletions

View File

@@ -1,10 +0,0 @@
# Link to template variables: https://pkg.go.dev/github.com/vektra/mockery/v3/config#TemplateData
template: testify
packages:
github.com/SigNoz/signoz/pkg/alertmanager:
config:
all: true
dir: '{{.InterfaceDir}}/mocks'
filename: "mocks.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}mock'

View File

@@ -247,8 +247,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
}
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil {
return nil, err
@@ -300,8 +299,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
}
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil {
return nil, err

View File

@@ -1,589 +0,0 @@
package rules
import (
"context"
"encoding/json"
"math"
"strconv"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
alertmanagermock "github.com/SigNoz/signoz/pkg/alertmanager/mocks"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/cachetest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qsRules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
)
type queryMatcherAny struct {
}
func (m *queryMatcherAny) Match(x string, y string) error {
return nil
}
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
target := 10.0
recovery := 5.0
buildRule := func() ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-alert",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
Labels: map[string]string{
"service.name": "frontend",
},
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.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: "probe_success",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
},
},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: &recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
},
},
},
},
NotificationSettings: &ruletypes.NotificationSettings{},
}
}
type testCase struct {
name string
values [][]interface{}
expectAlerts int
expectValue float64
}
cases := []testCase{
{
name: "return first valid point in case of test notification",
values: [][]interface{}{
{float64(3), "attr", time.Now()},
{float64(4), "attr", time.Now().Add(1 * time.Minute)},
},
expectAlerts: 1,
expectValue: 3,
},
{
name: "No data in DB so no alerts fired",
values: [][]interface{}{},
expectAlerts: 0,
},
{
name: "return first valid point in case of test notification skips NaN and Inf",
values: [][]interface{}{
{math.NaN(), "attr", time.Now()},
{math.Inf(1), "attr", time.Now().Add(1 * time.Minute)},
{float64(7), "attr", time.Now().Add(2 * time.Minute)},
},
expectAlerts: 1,
expectValue: 7,
},
{
name: "If found matching alert with given target value, return the alerting value rather than first valid point",
values: [][]interface{}{
{float64(1), "attr", time.Now()},
{float64(2), "attr", time.Now().Add(1 * time.Minute)},
{float64(3), "attr", time.Now().Add(2 * time.Minute)},
{float64(12), "attr", time.Now().Add(3 * time.Minute)},
},
expectAlerts: 1,
expectValue: 12,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rule := buildRule()
// Marshal rule to JSON as TestNotification expects
ruleBytes, err := json.Marshal(rule)
require.NoError(t, err)
// mocking the alertmanager + capturing the triggered test alerts
fAlert := alertmanagermock.NewMockAlertmanager(t)
// mock set notification config
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
// for saving temp alerts that are triggered via TestNotification
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
if tc.expectAlerts > 0 {
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
triggeredTestAlerts = append(triggeredTestAlerts, args.Get(3).(map[*alertmanagertypes.PostableAlert][]string))
}).Return(nil).Times(tc.expectAlerts)
}
cacheObj, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 1000,
MaxCost: 1 << 20,
},
})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
// Create SQLStore mock for SendAlerts function which queries organizations table
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
// Mock the organizations query that SendAlerts makes
// Bun generates: SELECT id FROM organizations LIMIT 1 (or SELECT "id" FROM "organizations" LIMIT 1)
orgRows := sqlStore.Mock().NewRows([]string{"id"}).AddRow(orgID.StringValue())
// Match bun's generated query pattern - bun may quote identifiers
sqlStore.Mock().ExpectQuery("SELECT (.+) FROM (.+)organizations(.+) LIMIT (.+)").WillReturnRows(orgRows)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Set up mock data for telemetry store
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "ts", Type: "DateTime"})
alertDataRows := cmock.NewRows(cols, tc.values)
mock := telemetryStore.Mock()
// Generate query arguments for the metric query
evalTime := time.Now().UTC()
evalWindow := 5 * time.Minute
evalDelay := time.Duration(0)
queryArgs := qsRules.GenerateMetricQueryCHArgs(
evalTime,
evalWindow,
evalDelay,
"probe_success",
metrictypes.Unspecified,
)
mock.ExpectQuery("*WITH __temporal_aggregation_cte*").
WithArgs(queryArgs...).
WillReturnRows(alertDataRows)
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
},
})
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
providerSettings := instrumentationtest.New().ToProviderSettings()
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(
nil,
telemetryStore,
prometheus,
"",
time.Duration(time.Second),
nil,
readerCache,
options,
)
// Create mock querierV5 with test values
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, readerCache)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)
mgrOpts := &qsRules.ManagerOptions{
Logger: zap.NewNop(),
SLogger: instrumentationtest.New().Logger(),
Cache: cacheObj,
Alertmanager: fAlert,
Querier: mockQuerier,
TelemetryStore: telemetryStore,
Reader: reader,
SqlStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
// Custom Test Notification function
PrepareTestRuleFunc: TestNotification,
}
mgr, err := qsRules.NewManager(mgrOpts)
require.NoError(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.expectAlerts, count)
if tc.expectAlerts > 0 {
// check if the alert has been triggered
require.Len(t, triggeredTestAlerts, 1)
var gotAlerts []*alertmanagertypes.PostableAlert
for a := range triggeredTestAlerts[0] {
gotAlerts = append(gotAlerts, a)
}
require.Len(t, gotAlerts, tc.expectAlerts)
// check if the alert has triggered with correct threshold value
if tc.expectValue != 0 {
assert.Equal(t, strconv.FormatFloat(tc.expectValue, 'f', -1, 64), gotAlerts[0].Annotations["value"])
}
} else {
// check if no alerts have been triggered
assert.Empty(t, triggeredTestAlerts)
}
})
}
}
func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
target := 10.0
buildRule := func() ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-prom-alert",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
Labels: map[string]string{
"service.name": "frontend",
},
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
PanelType: v3.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "A",
Query: "{\"test_metric\"}",
Disabled: false,
Stats: false,
},
},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},
},
NotificationSettings: &ruletypes.NotificationSettings{},
}
}
type testCase struct {
name string
values []struct {
offset time.Duration // offset from baseTime (negative = in the past)
value float64
}
expectAlerts int
expectValue float64
}
cases := []testCase{
{
name: "return first valid point in case of test notification",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, 3},
{-3 * time.Minute, 4},
},
expectAlerts: 1,
expectValue: 3,
},
{
name: "No data in DB so no alerts fired",
values: []struct {
offset time.Duration
value float64
}{},
expectAlerts: 0,
},
{
name: "return first valid point in case of test notification skips NaN and Inf",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, math.NaN()},
{-3 * time.Minute, math.Inf(1)},
{-2 * time.Minute, 7},
},
expectAlerts: 1,
expectValue: 7,
},
{
name: "If found matching alert with given target value, return the alerting value rather than first valid point",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, 1},
{-3 * time.Minute, 2},
{-2 * time.Minute, 3},
{-1 * time.Minute, 12},
},
expectAlerts: 1,
expectValue: 12,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Capture base time once per test case to ensure consistent timestamps
baseTime := time.Now().UTC()
rule := buildRule()
// Marshal rule to JSON as TestNotification expects
ruleBytes, err := json.Marshal(rule)
require.NoError(t, err)
// mocking the alertmanager + capturing the triggered test alerts
fAlert := alertmanagermock.NewMockAlertmanager(t)
// mock set notification config
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
// for saving temp alerts that are triggered via TestNotification
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
if tc.expectAlerts > 0 {
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
triggeredTestAlerts = append(triggeredTestAlerts, args.Get(3).(map[*alertmanagertypes.PostableAlert][]string))
}).Return(nil).Times(tc.expectAlerts)
}
cacheObj, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 1000,
MaxCost: 1 << 20,
},
})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
// Create SQLStore mock for SendAlerts function which queries organizations table
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
// Mock the organizations query that SendAlerts makes
orgRows := sqlStore.Mock().NewRows([]string{"id"}).AddRow(orgID.StringValue())
sqlStore.Mock().ExpectQuery("SELECT (.+) FROM (.+)organizations(.+) LIMIT (.+)").WillReturnRows(orgRows)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
for _, v := range tc.values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.value) || math.IsInf(v.value, 0) {
continue
}
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
"test_metric",
fingerprint,
sampleTimestamp,
v.value,
uint32(0), // flags - 0 means normal value
})
}
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
mock := telemetryStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
},
})
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(
nil,
telemetryStore,
promProvider,
"",
time.Duration(time.Second),
nil,
readerCache,
options,
)
mgrOpts := &qsRules.ManagerOptions{
Logger: zap.NewNop(),
SLogger: instrumentationtest.New().Logger(),
Cache: cacheObj,
Alertmanager: fAlert,
TelemetryStore: telemetryStore,
Reader: reader,
SqlStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
Prometheus: promProvider,
// Custom Test Notification function
PrepareTestRuleFunc: TestNotification,
}
mgr, err := qsRules.NewManager(mgrOpts)
require.NoError(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.expectAlerts, count)
if tc.expectAlerts > 0 {
// check if the alert has been triggered
require.Len(t, triggeredTestAlerts, 1)
var gotAlerts []*alertmanagertypes.PostableAlert
for a := range triggeredTestAlerts[0] {
gotAlerts = append(gotAlerts, a)
}
require.Len(t, gotAlerts, tc.expectAlerts)
// check if the alert has triggered with correct threshold value
if tc.expectValue != 0 && !math.IsNaN(tc.expectValue) && !math.IsInf(tc.expectValue, 0) {
assert.Equal(t, strconv.FormatFloat(tc.expectValue, 'f', -1, 64), gotAlerts[0].Annotations["value"])
}
} else {
// check if no alerts have been triggered
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -114,9 +114,9 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newExpression: string): void => {
const handleQueryValidation = useCallback((newQuery: string): void => {
try {
const validationResponse = validateQuery(newExpression);
const validationResponse = validateQuery(newQuery);
setValidation(validationResponse);
} catch (error) {
setValidation({
@@ -127,7 +127,7 @@ function QuerySearch({
}
}, []);
const getCurrentExpression = useCallback(
const getCurrentQuery = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
[],
);
@@ -167,14 +167,19 @@ function QuerySearch({
() => {
if (!isEditorReady) return;
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const newQuery = queryData.filter?.expression || '';
const currentQuery = getCurrentQuery();
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
if (newExpression) {
handleQueryValidation(newExpression);
/* eslint-disable-next-line sonarjs/no-collapsible-if */
if (newQuery !== currentQuery && !isFocused) {
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
// Only update if newQuery has a value, or if both are empty (initial state)
if (newQuery || !currentQuery) {
updateEditorValue(newQuery, { skipOnChange: true });
if (newQuery) {
handleQueryValidation(newQuery);
}
}
}
},
@@ -608,8 +613,8 @@ function QuerySearch({
};
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
handleQueryValidation(currentExpression);
const currentQuery = getCurrentQuery();
handleQueryValidation(currentQuery);
setIsFocused(false);
};
@@ -628,11 +633,11 @@ function QuerySearch({
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
const currentQuery = getCurrentQuery();
const newQuery = currentQuery
? `${currentQuery} AND ${exampleQuery}`
: exampleQuery;
updateEditorValue(newExpression);
updateEditorValue(newQuery);
};
// Helper function to render a badge for the current context mode
@@ -668,9 +673,9 @@ function QuerySearch({
if (word?.from === word?.to && !context.explicit) return null;
// Get current query from editor
const currentExpression = getCurrentExpression();
const currentQuery = editorRef.current?.state.doc.toString() || '';
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(currentExpression, cursorPos.ch);
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
// Define autocomplete options based on the context
let options: {
@@ -1166,8 +1171,8 @@ function QuerySearch({
if (queryContext.isInParenthesis) {
// Different suggestions based on the context within parenthesis or bracket
const currentExpression = getCurrentExpression();
const curChar = currentExpression.charAt(cursorPos.ch - 1) || '';
const currentQuery = editorRef.current?.state.doc.toString() || '';
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
if (curChar === '(' || curChar === '[') {
// Right after opening parenthesis/bracket
@@ -1316,7 +1321,7 @@ function QuerySearch({
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
@@ -1378,7 +1383,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
onRun(getCurrentQuery());
} else {
handleRunQuery();
}
@@ -1404,7 +1409,7 @@ function QuerySearch({
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
{getCurrentQuery() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable import/named */
import { EditorView } from '@uiw/react-codemirror';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder';
@@ -152,6 +151,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetKeys.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -170,8 +171,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
// Focus and type into the editor
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_KEY_TYPING);
await user.click(editor);
await user.type(editor, SAMPLE_KEY_TYPING);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
@@ -186,6 +187,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetValues.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -201,8 +204,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
@@ -238,6 +241,7 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('calls provided onRun on Mod-Enter', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
@@ -255,8 +259,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_STATUS_QUERY);
await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -276,6 +280,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedHandleRunQuery.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -291,8 +297,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -342,73 +348,4 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
{ timeout: 3000 },
);
});
it('handles queryData.filter.expression changes without triggering onChange', async () => {
// Spy on CodeMirror's EditorView.dispatch, which is invoked when updateEditorValue
// applies a programmatic change to the editor.
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const initialExpression = "service.name = 'frontend'";
const updatedExpression = "service.name = 'backend'";
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: {
expression: initialExpression,
},
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={initialQueryData}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize with the initial expression
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent).toBeInTheDocument();
const textContent = editorContent.textContent || '';
expect(textContent).toBe(initialExpression);
},
{ timeout: 3000 },
);
// Ensure the editor is explicitly blurred (not focused)
// Blur the actual CodeMirror editor container so that QuerySearch's onBlur handler runs.
// Note: In jsdom + CodeMirror we can't reliably assert the DOM text content changes when
// the expression is updated programmatically, but we can assert that:
// 1) The component continues to render, and
// 2) No onChange is fired for programmatic updates.
const updatedQueryData = {
...initialQueryData,
filter: {
expression: updatedExpression,
},
};
// Re-render with updated queryData.filter.expression
rerender(
<QuerySearch
onChange={onChange}
queryData={updatedQueryData}
dataSource={DataSource.LOGS}
/>,
);
// updateEditorValue should have resulted in a dispatch call + onChange should not have been called
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});
dispatchSpy.mockRestore();
});
});

View File

@@ -1,4 +1,3 @@
import userEvent from '@testing-library/user-event';
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -6,7 +5,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import { rest, server } from 'mocks-server/server';
import { UseQueryResult } from 'react-query';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SuccessResponse } from 'types/api';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -42,15 +41,13 @@ interface MockFilterConfig {
type: FiltersType;
}
const SERVICE_NAME_KEY = 'service.name';
const createMockFilter = (
overrides: Partial<MockFilterConfig> = {},
): MockFilterConfig => ({
// eslint-disable-next-line sonarjs/no-duplicate-string
title: 'Service Name',
attributeKey: {
key: SERVICE_NAME_KEY,
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
@@ -71,7 +68,7 @@ const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
? [
{
key: {
key: SERVICE_NAME_KEY,
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
@@ -191,222 +188,4 @@ describe('CheckboxFilter - User Flows', () => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
});
it('should update query filters when a checkbox is clicked', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Start with no active filters so clicking a checkbox creates one
mockUseQueryBuilder.mockReturnValue({
...createMockQueryBuilderData(false),
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// User unchecks the first value (`mq-kafka`)
await userEvent.click(checkboxes[0]);
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
expect(updatedFilters.items[0].op).toBe('not in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should set an IN filter with only the clicked value when using Only', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka', 'otel-demo'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for values to render
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Click on the value label to trigger the "Only" behavior
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
expect(updatedFilters.items[0].op).toBe('in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should clear filters for the attribute when using All', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const filtersForServiceName = updatedFilters.items.filter(
(item: any) => item.key?.key === SERVICE_NAME_KEY,
);
expect(filtersForServiceName).toHaveLength(0);
});
it('should extend an existing IN filter when checking an additional value', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN 'mq-kafka'
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: 'mq-kafka',
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox corresponds to 'mq-kafka' (already selected),
// second will be 'otel-demo' which we now select additionally.
await userEvent.click(checkboxes[1]);
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const [filterForServiceName] = updatedFilters.items;
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
expect(filterForServiceName.op).toBe('in');
expect(filterForServiceName.value).toEqual(['mq-kafka', 'otel-demo']);
});
});

View File

@@ -367,13 +367,7 @@ function VariableItem({
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
defaultValue: variableDefaultValue,
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,
@@ -731,6 +725,7 @@ function VariableItem({
className="default-input"
onChange={(e): void => {
setVariableTextboxValue(e.target.value);
setVariableDefaultValue(e.target.value);
}}
placeholder="Enter a default value (if any)..."
style={{ width: 400 }}

2
go.mod
View File

@@ -53,7 +53,7 @@ require (
github.com/smartystreets/goconvey v1.8.1
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.10.1
github.com/srikanthccv/ClickHouse-go-mock v0.13.0
github.com/srikanthccv/ClickHouse-go-mock v0.12.0
github.com/stretchr/testify v1.11.1
github.com/swaggest/jsonschema-go v0.3.78
github.com/swaggest/rest v0.2.75

4
go.sum
View File

@@ -962,8 +962,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/srikanthccv/ClickHouse-go-mock v0.13.0 h1:/b7DQphGkh29ocNtLh4DGmQxQYA0CfHz65Wy2zAH2GM=
github.com/srikanthccv/ClickHouse-go-mock v0.13.0/go.mod h1:LiiyBUdXNwB/1DE9rgK/8q9qjVYsTzg6WXQ/3mU3TeY=
github.com/srikanthccv/ClickHouse-go-mock v0.12.0 h1:KUzaWTwuqMc2uf5FylM/oAcTFdE2DdZjvISm9V0/NAA=
github.com/srikanthccv/ClickHouse-go-mock v0.12.0/go.mod h1:1oUmLtXEXOyS0EEWVKlKEfLfv9y02agCMAvD3tVnhlo=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"time"
@@ -12,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/singleflight"
)
const (
@@ -25,18 +23,10 @@ type APIKey struct {
headers []string
logger *slog.Logger
sharder sharder.Sharder
sfGroup *singleflight.Group
}
func NewAPIKey(store sqlstore.SQLStore, headers []string, logger *slog.Logger, sharder sharder.Sharder) *APIKey {
return &APIKey{
store: store,
uuid: authtypes.NewUUID(),
headers: headers,
logger: logger,
sharder: sharder,
sfGroup: &singleflight.Group{},
}
return &APIKey{store: store, uuid: authtypes.NewUUID(), headers: headers, logger: logger, sharder: sharder}
}
func (a *APIKey) Wrap(next http.Handler) http.Handler {
@@ -119,24 +109,11 @@ func (a *APIKey) Wrap(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
lastUsedCtx := context.WithoutCancel(r.Context())
_, _, _ = a.sfGroup.Do(apiKey.ID.StringValue(), func() (any, error) {
apiKey.LastUsed = time.Now()
_, err = a.
store.
BunDB().
NewUpdate().
Model(&apiKey).
Column("last_used").
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(lastUsedCtx)
if err != nil {
a.logger.ErrorContext(lastUsedCtx, "failed to update last used of api key", "error", err)
}
return true, nil
})
apiKey.LastUsed = time.Now()
_, err = a.store.BunDB().NewUpdate().Model(&apiKey).Column("last_used").Where("token = ?", apiKeyToken).Where("revoked = false").Exec(r.Context())
if err != nil {
a.logger.ErrorContext(r.Context(), "failed to update last used of api key", "error", err)
}
})

View File

@@ -191,13 +191,6 @@ func (r *BaseRule) currentAlerts() []*ruletypes.Alert {
return alerts
}
// ShouldSendUnmatched returns true if the rule should send unmatched samples
// during alert evaluation, even if they don't match the rule condition.
// This is useful in testing the rule.
func (r *BaseRule) ShouldSendUnmatched() bool {
return r.sendUnmatched
}
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint and
// the fingerprint is computed using the QueryResultLables.Hash() method.
// We use the QueryResultLables instead of labels as these labels are raw labels

View File

@@ -1,577 +0,0 @@
package rules
import (
"context"
"encoding/json"
"math"
"strconv"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
alertmanagermock "github.com/SigNoz/signoz/pkg/alertmanager/mocks"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/cachetest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
)
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
target := 10.0
recovery := 5.0
buildRule := func() ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-alert",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
Labels: map[string]string{
"service.name": "frontend",
},
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.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: "probe_success",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
},
},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: &recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
},
},
},
},
NotificationSettings: &ruletypes.NotificationSettings{},
}
}
type testCase struct {
name string
values [][]interface{}
expectAlerts int
expectValue float64
}
cases := []testCase{
{
name: "return first valid point in case of test notification",
values: [][]interface{}{
{float64(3), "attr", time.Now()},
{float64(4), "attr", time.Now().Add(1 * time.Minute)},
},
expectAlerts: 1,
expectValue: 3,
},
{
name: "No data in DB so no alerts fired",
values: [][]interface{}{},
expectAlerts: 0,
},
{
name: "return first valid point in case of test notification skips NaN and Inf",
values: [][]interface{}{
{math.NaN(), "attr", time.Now()},
{math.Inf(1), "attr", time.Now().Add(1 * time.Minute)},
{float64(7), "attr", time.Now().Add(2 * time.Minute)},
},
expectAlerts: 1,
expectValue: 7,
},
{
name: "If found matching alert with given target value, return the alerting value rather than first valid point",
values: [][]interface{}{
{float64(1), "attr", time.Now()},
{float64(2), "attr", time.Now().Add(1 * time.Minute)},
{float64(3), "attr", time.Now().Add(2 * time.Minute)},
{float64(12), "attr", time.Now().Add(3 * time.Minute)},
},
expectAlerts: 1,
expectValue: 12,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rule := buildRule()
// Marshal rule to JSON as TestNotification expects
ruleBytes, err := json.Marshal(rule)
require.NoError(t, err)
// mocking the alertmanager + capturing the triggered test alerts
fAlert := alertmanagermock.NewMockAlertmanager(t)
// mock set notification config
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
// for saving temp alerts that are triggered via TestNotification
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
if tc.expectAlerts > 0 {
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
triggeredTestAlerts = append(triggeredTestAlerts, args.Get(3).(map[*alertmanagertypes.PostableAlert][]string))
}).Return(nil).Times(tc.expectAlerts)
}
cacheObj, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 1000,
MaxCost: 1 << 20,
},
})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
// Create SQLStore mock for SendAlerts function which queries organizations table
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
// Mock the organizations query that SendAlerts makes
// Bun generates: SELECT id FROM organizations LIMIT 1 (or SELECT "id" FROM "organizations" LIMIT 1)
orgRows := sqlStore.Mock().NewRows([]string{"id"}).AddRow(orgID.StringValue())
// Match bun's generated query pattern - bun may quote identifiers
sqlStore.Mock().ExpectQuery("SELECT (.+) FROM (.+)organizations(.+) LIMIT (.+)").WillReturnRows(orgRows)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Set up mock data for telemetry store
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "ts", Type: "DateTime"})
alertDataRows := cmock.NewRows(cols, tc.values)
mock := telemetryStore.Mock()
// Generate query arguments for the metric query
evalTime := time.Now().UTC()
evalWindow := 5 * time.Minute
evalDelay := time.Duration(0)
queryArgs := GenerateMetricQueryCHArgs(
evalTime,
evalWindow,
evalDelay,
"probe_success",
metrictypes.Unspecified,
)
mock.ExpectQuery("*WITH __temporal_aggregation_cte*").
WithArgs(queryArgs...).
WillReturnRows(alertDataRows)
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
},
})
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
providerSettings := instrumentationtest.New().ToProviderSettings()
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(
nil,
telemetryStore,
prometheus,
"",
time.Duration(time.Second),
nil,
readerCache,
options,
)
// Create mock querierV5 with test values
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, readerCache)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)
mgrOpts := &ManagerOptions{
Logger: zap.NewNop(),
SLogger: instrumentationtest.New().Logger(),
Cache: cacheObj,
Alertmanager: fAlert,
Querier: mockQuerier,
TelemetryStore: telemetryStore,
Reader: reader,
SqlStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
}
mgr, err := NewManager(mgrOpts)
require.NoError(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.expectAlerts, count)
if tc.expectAlerts > 0 {
// check if the alert has been triggered
require.Len(t, triggeredTestAlerts, 1)
var gotAlerts []*alertmanagertypes.PostableAlert
for a := range triggeredTestAlerts[0] {
gotAlerts = append(gotAlerts, a)
}
require.Len(t, gotAlerts, tc.expectAlerts)
// check if the alert has triggered with correct threshold value
if tc.expectValue != 0 {
assert.Equal(t, strconv.FormatFloat(tc.expectValue, 'f', -1, 64), gotAlerts[0].Annotations["value"])
}
} else {
// check if no alerts have been triggered
assert.Empty(t, triggeredTestAlerts)
}
})
}
}
func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
target := 10.0
buildRule := func() ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-prom-alert",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
Labels: map[string]string{
"service.name": "frontend",
},
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
PanelType: v3.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "A",
Query: "{\"test_metric\"}",
Disabled: false,
Stats: false,
},
},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},
},
NotificationSettings: &ruletypes.NotificationSettings{},
}
}
type testCase struct {
name string
values []struct {
offset time.Duration // offset from baseTime (negative = in the past)
value float64
}
expectAlerts int
expectValue float64
}
cases := []testCase{
{
name: "return first valid point in case of test notification",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, 3},
{-3 * time.Minute, 4},
},
expectAlerts: 1,
expectValue: 3,
},
{
name: "No data in DB so no alerts fired",
values: []struct {
offset time.Duration
value float64
}{},
expectAlerts: 0,
},
{
name: "return first valid point in case of test notification skips NaN and Inf",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, math.NaN()},
{-3 * time.Minute, math.Inf(1)},
{-2 * time.Minute, 7},
},
expectAlerts: 1,
expectValue: 7,
},
{
name: "If found matching alert with given target value, return the alerting value rather than first valid point",
values: []struct {
offset time.Duration
value float64
}{
{-4 * time.Minute, 1},
{-3 * time.Minute, 2},
{-2 * time.Minute, 3},
{-1 * time.Minute, 12},
},
expectAlerts: 1,
expectValue: 12,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Capture base time once per test case to ensure consistent timestamps
baseTime := time.Now().UTC()
rule := buildRule()
// Marshal rule to JSON as TestNotification expects
ruleBytes, err := json.Marshal(rule)
require.NoError(t, err)
// mocking the alertmanager + capturing the triggered test alerts
fAlert := alertmanagermock.NewMockAlertmanager(t)
// mock set notification config
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
// for saving temp alerts that are triggered via TestNotification
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
if tc.expectAlerts > 0 {
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
triggeredTestAlerts = append(triggeredTestAlerts, args.Get(3).(map[*alertmanagertypes.PostableAlert][]string))
}).Return(nil).Times(tc.expectAlerts)
}
cacheObj, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 1000,
MaxCost: 1 << 20,
},
})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
// Create SQLStore mock for SendAlerts function which queries organizations table
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
// Mock the organizations query that SendAlerts makes
orgRows := sqlStore.Mock().NewRows([]string{"id"}).AddRow(orgID.StringValue())
sqlStore.Mock().ExpectQuery("SELECT (.+) FROM (.+)organizations(.+) LIMIT (.+)").WillReturnRows(orgRows)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
for _, v := range tc.values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.value) || math.IsInf(v.value, 0) {
continue
}
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
"test_metric",
fingerprint,
sampleTimestamp,
v.value,
uint32(0), // flags - 0 means normal value
})
}
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
mock := telemetryStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
},
})
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(
nil,
telemetryStore,
promProvider,
"",
time.Duration(time.Second),
nil,
readerCache,
options,
)
mgrOpts := &ManagerOptions{
Logger: zap.NewNop(),
SLogger: instrumentationtest.New().Logger(),
Cache: cacheObj,
Alertmanager: fAlert,
TelemetryStore: telemetryStore,
Reader: reader,
SqlStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
Prometheus: promProvider,
}
mgr, err := NewManager(mgrOpts)
require.NoError(t, err)
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
assert.Equal(t, tc.expectAlerts, count)
if tc.expectAlerts > 0 {
// check if the alert has been triggered
require.Len(t, triggeredTestAlerts, 1)
var gotAlerts []*alertmanagertypes.PostableAlert
for a := range triggeredTestAlerts[0] {
gotAlerts = append(gotAlerts, a)
}
require.Len(t, gotAlerts, tc.expectAlerts)
// check if the alert has triggered with correct threshold value
if tc.expectValue != 0 && !math.IsNaN(tc.expectValue) && !math.IsInf(tc.expectValue, 0) {
assert.Equal(t, strconv.FormatFloat(tc.expectValue, 'f', -1, 64), gotAlerts[0].Annotations["value"])
}
} else {
// check if no alerts have been triggered
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -138,8 +138,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
var resultVector ruletypes.Vector
for _, series := range res {
resultSeries, err := r.Threshold.Eval(toCommonSeries(series), r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil {
return nil, err

View File

@@ -489,8 +489,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
}
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil {
return nil, err
@@ -569,8 +568,7 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
}
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil {
return nil, err

View File

@@ -2,7 +2,6 @@ package rules
import (
"context"
"math"
"strings"
"testing"
"time"
@@ -1520,283 +1519,6 @@ func TestThresholdRuleEval_MatchPlusCompareOps(t *testing.T) {
}
// 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: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: &recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
},
},
}
logger := instrumentationtest.New().Logger()
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, nil, logger, WithEvalDelay(2*time.Minute))
require.NoError(t, err)
now := time.Now()
series := v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
{Timestamp: now.Add(2 * time.Minute).UnixMilli(), Value: 5},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "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: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
now := time.Now()
tests := []recoveryTestCase{
{
description: "sendUnmatched returns first valid point",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(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: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(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: v3.Series{
Points: []v3.Point{
{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: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(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: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
tc := recoveryTestCase{
description: "recovery target present but not met, sendUnmatched false",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(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 {
@@ -1855,21 +1577,12 @@ func runEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases [
}
evalData := ruletypes.EvalData{
ActiveAlerts: activeAlerts,
SendUnmatched: c.sendUnmatched,
ActiveAlerts: activeAlerts,
}
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

View File

@@ -27,10 +27,6 @@ type recoveryTestCase struct {
expectedTarget float64
expectedRecoveryTarget float64
thresholdName string // for hash calculation
// Optional fields for SendUnmatched scenarios
sendUnmatched bool // whether to set EvalData.SendUnmatched
expectSamples *int // if set, assert exact sample count
expectedSampleValue float64 // used when expectSamples is set
}
// thresholdExpectation defines expected behavior for a single threshold in multi-threshold tests

View File

@@ -1,79 +0,0 @@
package rules
import (
"time"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
)
// GenerateMetricQueryCHArgs generates query arguments for metric queries used in tests.
// It calculates the time range, builds time series CTE args, temporal aggregation args,
// and spatial aggregation args to match the actual query builder behavior.
func GenerateMetricQueryCHArgs(
evalTime time.Time,
evalWindow time.Duration,
evalDelay time.Duration,
metricName string,
temporality metrictypes.Temporality,
) []interface{} {
// Calculate time range
startTime := evalTime.Add(-evalWindow)
endTime := evalTime
startMs := startTime.UnixMilli()
endMs := endTime.UnixMilli()
// Apply eval delay if present
if evalDelay > 0 {
startMs = startMs - int64(evalDelay.Milliseconds())
endMs = endMs - int64(evalDelay.Milliseconds())
}
// Round to nearest minute
startMs = startMs - (startMs % (60 * 1000))
endMs = endMs - (endMs % (60 * 1000))
start := uint64(startMs)
end := uint64(endMs)
// Step1: Build time series CTE args
// Adjust start time to nearest hour
oneHourInMilliseconds := uint64(time.Hour.Milliseconds())
// start time for filtering signoz_metrics.time_series_v4 with start time
timeSeriesCTEStartTime := start - (start % oneHourInMilliseconds)
queryArgs := []interface{}{
metricName,
timeSeriesCTEStartTime,
end,
}
// Add temporality if specified
if temporality == metrictypes.Unknown {
temporality = metrictypes.Unspecified
}
if temporality != metrictypes.Unknown {
queryArgs = append(queryArgs, temporality.StringValue())
}
// Add normalized flag
queryArgs = append(queryArgs, false)
// Step2: Add temporal aggregation args
// build args for filtering signoz_metrics.distributed_samples_v4 table
temporalAggArgs := []interface{}{
metricName,
start,
end,
}
queryArgs = append(queryArgs, temporalAggArgs...)
// Add spatial aggregation args
spatialAggArgs := []interface{}{
0, // isNaN check
}
queryArgs = append(queryArgs, spatialAggArgs...)
return queryArgs
}

View File

@@ -63,11 +63,6 @@ type EvalData struct {
// used to check if a sample is part of an active alert
// when evaluating the recovery threshold.
ActiveAlerts map[uint64]struct{}
// SendUnmatched is a flag to return samples
// even if they don't match the rule condition.
// This is useful in testing the rule.
SendUnmatched bool
}
// HasActiveAlert checks if the given sample figerprint is active
@@ -136,24 +131,6 @@ func (r BasicRuleThresholds) Eval(series v3.Series, unit string, evalData EvalDa
smpl.TargetUnit = threshold.TargetUnit
resultVector = append(resultVector, smpl)
continue
} else if evalData.SendUnmatched {
// Sanitise the series points to remove any NaN or Inf values
series.Points = removeGroupinSetPoints(series)
if len(series.Points) == 0 {
continue
}
// prepare the sample with the first point of the series
smpl := Sample{
Point: Point{T: series.Points[0].Timestamp, V: series.Points[0].Value},
Metric: PrepareSampleLabelsForRule(series.Labels, threshold.Name),
Target: *threshold.TargetValue,
TargetUnit: threshold.TargetUnit,
}
if threshold.RecoveryTarget != nil {
smpl.RecoveryTarget = threshold.RecoveryTarget
}
resultVector = append(resultVector, smpl)
continue
}
// Prepare alert hash from series labels and threshold name if recovery target option was provided