Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
2a9246bfe3 chore: dummy commit to trigger review 2026-10-13 11:59:19 +05:30
Naman Verma
e59afbec38 chore: better code comments 2026-10-13 11:30:46 +05:30
Naman Verma
b1e2164739 fix: make related logs/traces link carry the relevant query from a rule 2026-10-13 11:16:59 +05:30
14 changed files with 544 additions and 172 deletions

View File

@@ -3,42 +3,51 @@ package contextlinks
import (
"encoding/json"
"net/url"
"slices"
"strconv"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
func PrepareParamsForTracesV5(start, end time.Time, labels map[string]string, queries []BuilderQuery, queryType qbtypes.QueryType) url.Values {
// the traces explorer takes its time params in nanoseconds
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), labels, queries)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
func PrepareParamsForLogsV5(start, end time.Time, labels map[string]string, queries []BuilderQuery) url.Values {
// the logs explorer takes its time params in milliseconds
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), labels, queries)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, labels map[string]string, queries []BuilderQuery) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
if len(queries) == 0 {
queries = []BuilderQuery{{}}
}
queryData := make([]LinkQuery, 0, len(queries))
for _, query := range queries {
queryData = append(queryData, LinkQuery{
QueryName: query.Name,
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: PrepareFilterExpression(labels, query.Filter, query.GroupBy)},
})
}
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryData: queryData,
QueryFormulas: make([]string, 0),
},
}
@@ -52,40 +61,56 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
return params
}
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
func ExtractBuilderQueries(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal) []BuilderQuery {
if condition == nil || condition.CompositeQuery == nil {
return nil
}
names := resolveSelectedQueryNames(condition)
switch signal {
case telemetrytypes.SignalLogs:
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
return extractBuilderQueries[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal)
case telemetrytypes.SignalTraces:
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
return extractBuilderQueries[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal)
}
return "", nil, false
return nil
}
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
var q qbtypes.QueryBuilderQuery[T]
found := false
func resolveSelectedQueryNames(condition *ruletypes.RuleCondition) []string {
selected := condition.SelectedQuery
if selected == "" {
selected = condition.SelectedQueryName()
}
for _, query := range condition.CompositeQuery.Queries {
if query.GetQueryName() != selected {
continue
}
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok {
return formula.ExtractReferencedQueryNames()
}
return []string{selected}
}
return nil
}
func extractBuilderQueries[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal) []BuilderQuery {
var matches []BuilderQuery
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
q = spec
found = true
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T])
// spec.Disabled is deliberately not checked: a formula's inputs are
// usually hidden in the UI but must still make it into the link
if !ok || spec.Signal != signal || !slices.Contains(names, spec.Name) {
continue
}
filterExpr := ""
if spec.Filter != nil {
filterExpr = spec.Filter.Expression
}
matches = append(matches, BuilderQuery{Name: spec.Name, Filter: filterExpr, GroupBy: spec.GroupBy})
}
if !found || q.Signal != signal {
return "", nil, false
}
filterExpr := ""
if q.Filter != nil {
filterExpr = q.Filter.Expression
}
return filterExpr, q.GroupBy, true
return matches
}

View File

@@ -4,12 +4,12 @@ import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilderQueryForSignal(t *testing.T) {
func TestExtractBuilderQueries(t *testing.T) {
logQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -19,62 +19,145 @@ func TestBuilderQueryForSignal(t *testing.T) {
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
},
}
hiddenLogQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Disabled: true,
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'timeout'"},
},
}
traceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Name: "C",
Signal: telemetrytypes.SignalTraces,
},
}
promQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
Spec: qbtypes.PromQuery{Name: "D"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Name: "E",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
formula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
require.True(t, found)
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "service.name", groupBy[0].Name)
})
testCases := []struct {
name string
condition *ruletypes.RuleCondition
signal telemetrytypes.Signal
expectedBuilderQueries []BuilderQuery
}{
{
name: "SelectedLogsQuery_AmongMixedQueries",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "SelectedFormula_HiddenInputsInCompositeQueryOrder",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F1",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, formula}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "B", Filter: "body CONTAINS 'timeout'"},
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "NoSelectedQuery_FallsBackToFormula",
condition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, formula}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
{Name: "B", Filter: "body CONTAINS 'timeout'"},
},
},
{
name: "SelectedTracesQuery_WithoutFilter",
condition: &ruletypes.RuleCondition{
SelectedQuery: "C",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalTraces,
expectedBuilderQueries: []BuilderQuery{{Name: "C"}},
},
{
name: "SelectedAITraceQuery_CountsAsTraces",
condition: &ruletypes.RuleCondition{
SelectedQuery: "E",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
},
signal: telemetrytypes.SignalTraces,
expectedBuilderQueries: []BuilderQuery{
{Name: "E", Filter: "trace.input_tokens > 1000", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}}},
},
},
{
name: "SelectedQuery_OfAnotherSignal",
condition: &ruletypes.RuleCondition{
SelectedQuery: "C",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
},
{
name: "SelectedQuery_NotABuilderQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
},
signal: telemetrytypes.SignalLogs,
},
{
name: "UnsupportedSignal",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
},
signal: telemetrytypes.SignalMetrics,
},
{
name: "NoCompositeQuery",
condition: &ruletypes.RuleCondition{SelectedQuery: "A"},
signal: telemetrytypes.SignalLogs,
},
}
t.Run("traces query without filter", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Empty(t, filterExpr)
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("no builder queries at all", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("unsupported signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
assert.False(t, found)
})
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expectedBuilderQueries, ExtractBuilderQueries(testCase.condition, testCase.signal))
})
}
}

View File

@@ -1,6 +1,7 @@
package contextlinks
import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
@@ -10,9 +11,19 @@ type FilterExpression struct {
Expression string `json:"expression,omitempty"`
}
// BuilderQuery is the part of an alert's builder query that carries over into
// an explorer link.
type BuilderQuery struct {
Name string
Filter string
GroupBy []qbtypes.GroupByKey
}
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
// the explorer names an unnamed query A, so a link with several queries names each
QueryName string `json:"queryName,omitempty"`
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`

View File

@@ -17,10 +17,9 @@ import (
// filter and the entry's labels, so a history entry can be opened in the
// explorer with the context that produced it.
type relatedLinkBuilder struct {
alertType ruletypes.AlertType
evaluation ruletypes.Evaluation
filterExpr string
groupBy []qbtypes.GroupByKey
alertType ruletypes.AlertType
evaluation ruletypes.Evaluation
builderQueries []contextlinks.BuilderQuery
}
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
@@ -65,7 +64,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
builder.builderQueries = contextlinks.ExtractBuilderQueries(rule.RuleCondition, signal)
return builder
}
@@ -89,15 +88,13 @@ func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, st
return rulestatehistorytypes.RelatedLinks{}
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
switch b.alertType {
case ruletypes.AlertTypeLogs:
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, lbls, b.builderQueries).Encode()}
case ruletypes.AlertTypeTraces:
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, lbls, b.builderQueries, qbtypes.QueryTypeBuilder).Encode()}
case ruletypes.AlertTypeAITraces:
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, lbls, b.builderQueries, qbtypes.QueryTypeBuilderAI).Encode()}
}
return rulestatehistorytypes.RelatedLinks{}
}

View File

@@ -65,6 +65,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka"
@@ -802,47 +803,13 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
// alerts have 2 minutes delay built in, so we need to subtract that from the start time
// to get the correct query range
start := end.Add(-rule.EvalWindow.Duration() - 3*time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
switch rule.AlertType {
case ruletypes.AlertTypeLogs:
builderQueries := contextlinks.ExtractBuilderQueries(rule.RuleCondition, telemetrytypes.SignalLogs)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, lbls, builderQueries).Encode()
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
builderQueries := contextlinks.ExtractBuilderQueries(rule.RuleCondition, telemetrytypes.SignalTraces)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, lbls, builderQueries, rule.AlertType.BuilderQueryType()).Encode()
}
}
}

View File

@@ -86,8 +86,6 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -95,24 +93,15 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
builderQueries := contextlinks.ExtractBuilderQueries(r.ruleCondition, telemetrytypes.SignalLogs)
if len(builderQueries) == 0 {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
return contextlinks.PrepareParamsForLogsV5(start, end, lbls.Map(), builderQueries)
}
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -120,19 +109,12 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
builderQueries := contextlinks.ExtractBuilderQueries(r.ruleCondition, telemetrytypes.SignalTraces)
if len(builderQueries) == 0 {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
return contextlinks.PrepareParamsForTracesV5(start, end, lbls.Map(), builderQueries, r.typ.BuilderQueryType())
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {

View File

@@ -205,7 +205,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
@@ -270,7 +270,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"logs","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
@@ -335,7 +335,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"traces","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForTraces(t *testing.T) {
@@ -401,7 +401,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"traces","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestThresholdRuleLabelNormalization(t *testing.T) {

View File

@@ -131,6 +131,22 @@ func (f QueryBuilderFormula) Validate() error {
return nil
}
func (f QueryBuilderFormula) ExtractReferencedQueryNames() []string {
// parsed the way the evaluator parses it, so the names match what it resolves
expression, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs())
if err != nil {
return nil
}
var names []string
for _, variable := range expression.Vars() {
if !slices.Contains(names, variable) {
names = append(names, variable)
}
}
return names
}
// small container to store the query name and index or alias reference
// for a variable in the formula expression
// read below for more details on aggregation references.

View File

@@ -150,13 +150,14 @@ def parse_related_link(link: str) -> dict:
}
def assert_related_link_query(link: dict, data_source: str, expression_pieces: list[str]) -> None:
def assert_related_link_queries(link: dict, data_source: str, expression_pieces_by_query_name: dict[str, list[str]]) -> None:
query_data = link["composite_query"]["builder"]["queryData"]
assert len(query_data) == 1
assert query_data[0]["dataSource"] == data_source
expression = query_data[0]["filter"]["expression"]
for piece in expression_pieces:
assert piece in expression, f"expected {piece} in link filter expression: {expression}"
assert [query["queryName"] for query in query_data] == list(expression_pieces_by_query_name)
for query in query_data:
assert query["dataSource"] == data_source
expression = query["filter"]["expression"]
for piece in expression_pieces_by_query_name[query["queryName"]]:
assert piece in expression, f"expected {piece} in link filter expression: {expression}"
def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, wait_seconds: int = 60) -> tuple[dict, int]:

View File

@@ -0,0 +1,8 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }

View File

@@ -0,0 +1,104 @@
{
"alert": "rule_state_history_formula_logs",
"ruleType": "threshold_rule",
"alertType": "LOGS_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": true,
"filter": {
"expression": "body CONTAINS 'payment success'"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "count()"
}
]
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"disabled": true,
"filter": {
"expression": "severity_text = 'INFO'"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "count()"
}
]
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A / B * 100"
}
}
]
},
"selectedQueryName": "F1"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -0,0 +1,8 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }

View File

@@ -0,0 +1,88 @@
{
"alert": "rule_state_history_multi_query_logs",
"ruleType": "threshold_rule",
"alertType": "LOGS_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"filter": {
"expression": "body CONTAINS 'payment success'"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "count()"
}
]
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"filter": {
"expression": "severity_text = 'INFO'"
},
"aggregations": [
{
"expression": "count()"
}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -3,7 +3,7 @@ from datetime import UTC, datetime, timedelta
from fixtures import types
from fixtures.alerts import (
assert_related_link_query,
assert_related_link_queries,
get_rule_history_top_contributors,
labels_to_map,
parse_related_link,
@@ -46,7 +46,7 @@ def test_logs_rule_history_related_links(
link = parse_related_link(item["relatedLogsLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1000
assert_related_link_query(link, "logs", ["payment success", "service.name", "payment-service"])
assert_related_link_queries(link, "logs", {"A": ["payment success", "service.name", "payment-service"]})
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
@@ -60,7 +60,7 @@ def test_logs_rule_history_related_links(
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
assert contributor_link["start"] == query_start_ms
assert contributor_link["end"] == query_end_ms
assert_related_link_query(contributor_link, "logs", ["payment success", "service.name", "payment-service"])
assert_related_link_queries(contributor_link, "logs", {"A": ["payment success", "service.name", "payment-service"]})
def test_traces_rule_history_related_links(
@@ -90,7 +90,7 @@ def test_traces_rule_history_related_links(
link = parse_related_link(item["relatedTracesLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1_000_000_000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1_000_000_000
assert_related_link_query(link, "traces", ["http.request.path", "/order", "service.name", "order-service"])
assert_related_link_queries(link, "traces", {"A": ["http.request.path", "/order", "service.name", "order-service"]})
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "order-service"]
@@ -104,7 +104,7 @@ def test_traces_rule_history_related_links(
contributor_link = parse_related_link(contributors[0]["relatedTracesLink"])
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["http.request.path", "/order", "service.name", "order-service"])
assert_related_link_queries(contributor_link, "traces", {"A": ["http.request.path", "/order", "service.name", "order-service"]})
def test_ai_traces_rule_history_related_links(
@@ -134,7 +134,7 @@ def test_ai_traces_rule_history_related_links(
link = parse_related_link(item["relatedAITracesLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1_000_000_000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1_000_000_000
assert_related_link_query(link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
assert_related_link_queries(link, "traces", {"A": ["trace.input_tokens", "100", "service.name", "llm-gateway"]})
# the AI explorer only resolves trace.* fields for builder_ai_query
assert link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
@@ -149,5 +149,87 @@ def test_ai_traces_rule_history_related_links(
contributor_link = parse_related_link(contributors[0]["relatedAITracesLink"])
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
assert_related_link_queries(contributor_link, "traces", {"A": ["trace.input_tokens", "100", "service.name", "llm-gateway"]})
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
def test_multi_query_logs_rule_history_related_links(
signoz: types.SigNoz,
create_alert_rule_with_channel: Callable[[str], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
insert_alert_data(
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_multi_query_logs/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json")
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
assert item.get("relatedLogsLink", "") != ""
# the rule evaluates query A alone, so query B plays no part in the alert
# and the link leaves it out
link = parse_related_link(item["relatedLogsLink"])
assert_related_link_queries(link, "logs", {"A": ["payment success", "service.name", "payment-service"]})
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
assert len(contributors) == 1
assert contributors[0].get("relatedLogsLink", "") != ""
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
assert_related_link_queries(contributor_link, "logs", {"A": ["payment success", "service.name", "payment-service"]})
def test_formula_logs_rule_history_related_links(
signoz: types.SigNoz,
create_alert_rule_with_channel: Callable[[str], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
insert_alert_data(
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_formula_logs/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_formula_logs/rule.json")
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
assert item.get("relatedLogsLink", "") != ""
# the rule evaluates formula F1 over the hidden queries A and B, so the
# link carries both inputs, each narrowed to the firing series' labels
link = parse_related_link(item["relatedLogsLink"])
assert_related_link_queries(
link,
"logs",
{
"A": ["payment success", "service.name", "payment-service"],
"B": ["severity_text", "INFO", "service.name", "payment-service"],
},
)
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
assert len(contributors) == 1
assert contributors[0].get("relatedLogsLink", "") != ""
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
assert_related_link_queries(
contributor_link,
"logs",
{
"A": ["payment success", "service.name", "payment-service"],
"B": ["severity_text", "INFO", "service.name", "payment-service"],
},
)