Compare commits

...

7 Commits

Author SHA1 Message Date
Naman Verma
e6c8bae0c7 revert: comments 2026-10-16 14:00:38 +05:30
Naman Verma
6210c0ac4b Merge branch 'main' into nv/context-links 2026-10-16 13:55:18 +05:30
Naman Verma
0f370aac64 fix: do A OR B for formula queries 2026-10-16 13:49:48 +05:30
Naman Verma
b85b2fa87c Merge branch 'main' into nv/context-links 2026-10-16 11:18:29 +05:30
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
10 changed files with 529 additions and 96 deletions

View File

@@ -3,10 +3,13 @@ package contextlinks
import (
"encoding/json"
"net/url"
"slices"
"strconv"
"strings"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -52,40 +55,67 @@ 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) {
switch signal {
case telemetrytypes.SignalLogs:
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
case telemetrytypes.SignalTraces:
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
func PrepareRuleFilterExpression(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal, labels map[string]string) (string, bool) {
var expressions []string
if condition != nil && condition.CompositeQuery != nil {
names := resolveSelectedQueryNames(condition)
switch signal {
case telemetrytypes.SignalLogs:
expressions = prepareFilterExpressions[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal, labels)
case telemetrytypes.SignalTraces:
expressions = prepareFilterExpressions[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal, labels)
}
}
return "", nil, false
switch {
case len(expressions) == 0:
// a ClickHouse SQL alert has no builder query; its link filters on the labels alone
return PrepareFilterExpression(labels, "", nil), false
case len(expressions) == 1:
return expressions[0], true
case slices.Contains(expressions, ""):
// an unfiltered formula input matches everything, so the merged filter does too
return "", true
}
// the explorer list views render one query only, so a formula's inputs merge into one filter
return "(" + strings.Join(expressions, ") OR (") + ")", true
}
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 prepareFilterExpressions[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal, labels map[string]string) []string {
var expressions []string
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
}
expressions = append(expressions, PrepareFilterExpression(labels, filterExpr, 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 expressions
}

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 TestPrepareRuleFilterExpression(t *testing.T) {
logQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -19,62 +19,183 @@ 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'"},
},
}
unfilteredLogQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "C",
Signal: telemetrytypes.SignalLogs,
},
}
traceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Name: "D",
Signal: telemetrytypes.SignalTraces,
},
}
promQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
Spec: qbtypes.PromQuery{Name: "E"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Name: "F",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
errorRateFormula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
}
unfilteredFormula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F2", Expression: "A / C"},
}
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
labels map[string]string
expectedExpression string
expectedFound bool
}{
{
name: "SelectedLogsQuery_AmongMixedQueries",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedFormula_MergesHiddenInputsInCompositeQueryOrder",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F1",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, errorRateFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "((body CONTAINS 'timeout') AND service.name='checkout') OR ((severity_text='ERROR') AND service.name='checkout')",
expectedFound: true,
},
{
name: "SelectedFormula_UnfilteredInputMatchesEverything",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F2",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, unfilteredLogQuery, unfilteredFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: true,
},
{
name: "NoSelectedQuery_FallsBackToFormula",
condition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, errorRateFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "((severity_text='ERROR') AND service.name='checkout') OR ((body CONTAINS 'timeout') AND service.name='checkout')",
expectedFound: true,
},
{
name: "SelectedTracesQuery_WithoutFilter",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalTraces,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedAITraceQuery_CountsAsTraces",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
},
signal: telemetrytypes.SignalTraces,
labels: map[string]string{"session.id": "s1"},
expectedExpression: "(trace.input_tokens>1000) AND session.id='s1'",
expectedFound: true,
},
{
name: "SelectedQuery_OfAnotherSignal_LabelsOnly",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: false,
},
{
name: "SelectedQuery_NotABuilderQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "E",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: false,
},
{
name: "UnsupportedSignal_LabelsOnly",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
},
signal: telemetrytypes.SignalMetrics,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: false,
},
{
name: "NoCompositeQuery",
condition: &ruletypes.RuleCondition{SelectedQuery: "A"},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: false,
},
}
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) {
expression, found := PrepareRuleFilterExpression(testCase.condition, testCase.signal, testCase.labels)
assert.Equal(t, testCase.expectedExpression, expression)
assert.Equal(t, testCase.expectedFound, found)
})
}
}

View File

@@ -18,9 +18,9 @@ import (
// explorer with the context that produced it.
type relatedLinkBuilder struct {
alertType ruletypes.AlertType
signal telemetrytypes.Signal
evaluation ruletypes.Evaluation
filterExpr string
groupBy []qbtypes.GroupByKey
condition *ruletypes.RuleCondition
}
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
@@ -49,7 +49,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
builder := &relatedLinkBuilder{alertType: rule.AlertType}
builder := &relatedLinkBuilder{alertType: rule.AlertType, signal: signal, condition: rule.RuleCondition}
if rule.Evaluation != nil {
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
builder.evaluation = evaluation
@@ -63,10 +63,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
// 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)
return builder
}
@@ -89,7 +85,9 @@ func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, st
return rulestatehistorytypes.RelatedLinks{}
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
whereClause, _ := contextlinks.PrepareRuleFilterExpression(b.condition, b.signal, lbls)
switch b.alertType {
case ruletypes.AlertTypeLogs:

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" {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
whereClause, found := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalLogs, lbls.Map())
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
}
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,18 +109,11 @@ 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" {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
whereClause, found := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalTraces, lbls.Map())
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
}

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

@@ -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

@@ -151,3 +151,81 @@ def test_ai_traces_rule_history_related_links(
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 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 its filter stays out of the link
link = parse_related_link(item["relatedLogsLink"])
assert link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND 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 contributor_link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND 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's one query ORs both filters, each narrowed to the firing series' labels
link = parse_related_link(item["relatedLogsLink"])
assert link["composite_query"]["builder"]["queryData"] == [
{
"dataSource": "logs",
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND 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 contributor_link["composite_query"]["builder"]["queryData"] == [
{
"dataSource": "logs",
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND service.name='payment-service')"},
}
]