Compare commits

...

2 Commits

Author SHA1 Message Date
Cursor Agent
7f02458ef8 fix(alerting): include queryFormulas in rule validation and evaluation 2026-04-08 18:19:03 +00:00
Piyush Singariya
8bfadbc197 fix: has value fixes (#10864)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-04-08 13:22:54 +00:00
7 changed files with 115 additions and 12 deletions

View File

@@ -90,8 +90,9 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
},
NoCache: true,
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
queries := r.Condition().CompositeQuery.QueriesIncludingFormulas()
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(queries))
copy(req.CompositeQuery.Queries, queries)
return req, nil
}

View File

@@ -101,6 +101,75 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
}
}
func TestPrepareQueryRangeIncludesQueryFormulas(t *testing.T) {
target := 10.0
postableRule := ruletypes.PostableRule{
AlertName: "prepare query range includes formulas",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_calls_total",
Temporality: metrictypes.Delta,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
},
},
},
QueryFormulas: []qbtypes.QueryBuilderFormula{
{
Name: "F1",
Expression: "A",
},
},
},
Target: &target,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: ruletypes.CriticalThresholdName,
TargetValue: &target,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
},
},
},
},
Version: "v5",
}
logger := instrumentationtest.New().Logger()
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger)
require.NoError(t, err)
req, err := rule.prepareQueryRange(context.Background(), time.Now())
require.NoError(t, err)
require.Len(t, req.CompositeQuery.Queries, 2)
assert.Equal(t, "A", req.CompositeQuery.Queries[0].GetQueryName())
assert.Equal(t, "F1", req.CompositeQuery.Queries[1].GetQueryName())
assert.Equal(t, qbtypes.QueryTypeFormula, req.CompositeQuery.Queries[1].Type)
}
func TestNormalizeLabelName(t *testing.T) {
cases := []struct {
labelName string

View File

@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
case "has":
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAny":
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAll":
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
}
conds = append(conds, cond)
}

View File

@@ -631,7 +631,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
filter: "hasAll(body.user.permissions, ['read', 'write'])",
expected: TestExpected{
WhereClause: "hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?)",
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"read", "write"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Args: []any{uint64(1747945619), uint64(1747983448), []any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
@@ -757,7 +757,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
filter: "hasAny(education[].awards[].participated[].members, ['Piyush', 'Tushar'])",
expected: TestExpected{
WhereClause: "hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"Piyush", "Tushar"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Args: []any{uint64(1747945619), uint64(1747983448), []any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{

View File

@@ -85,7 +85,8 @@ var (
)
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries"`
Queries []qbtypes.QueryEnvelope `json:"queries"`
QueryFormulas []qbtypes.QueryBuilderFormula `json:"queryFormulas,omitempty"`
PanelType PanelType `json:"panelType"`
QueryType QueryType `json:"queryType"`
@@ -94,6 +95,22 @@ type AlertCompositeQuery struct {
Unit string `json:"unit,omitempty"`
}
func (acq *AlertCompositeQuery) QueriesIncludingFormulas() []qbtypes.QueryEnvelope {
if acq == nil {
return nil
}
queries := make([]qbtypes.QueryEnvelope, 0, len(acq.Queries)+len(acq.QueryFormulas))
queries = append(queries, acq.Queries...)
for _, formula := range acq.QueryFormulas {
queries = append(queries, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: formula,
})
}
return queries
}
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery"`
CompareOperator CompareOperator `json:"op"`
@@ -114,7 +131,7 @@ func (rc *RuleCondition) SelectedQueryName() string {
queryNames := map[string]struct{}{}
for _, query := range rc.CompositeQuery.Queries {
for _, query := range rc.CompositeQuery.QueriesIncludingFormulas() {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if !spec.Disabled {

View File

@@ -406,19 +406,20 @@ func (r *PostableRule) Validate() error {
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
queries := r.RuleCondition.CompositeQuery.QueriesIncludingFormulas()
if len(queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
cq := &qbtypes.CompositeQuery{Queries: r.RuleCondition.CompositeQuery.Queries}
cq := &qbtypes.CompositeQuery{Queries: queries}
if err := cq.Validate(qbtypes.GetValidationOptions(qbtypes.RequestTypeTimeSeries)...); err != nil {
errs = append(errs, err)
}
}
}
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil {
found := false
for _, query := range r.RuleCondition.CompositeQuery.Queries {
for _, query := range r.RuleCondition.CompositeQuery.QueriesIncludingFormulas() {
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
found = true
break

View File

@@ -296,6 +296,21 @@ func TestValidate_PostableRule_Common(t *testing.T) {
wantErr: true,
errSubstr: "selectedQueryName",
},
{
name: "selectedQueryName matches queryFormulas entry",
json: `{
"alert": "Test", "version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}],
"queryFormulas": [{"name": "F1", "expression": "A", "disabled": false}]
},
"target": 10.0, "matchType": "1", "op": "1",
"selectedQueryName": "F1"
}
}`,
},
{
name: "empty selectedQueryName is ok (optional)",
json: validV1Builder(),