diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 3e7b737ce2..8c697c895b 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -352,3 +352,36 @@ const TIMESTAMP = "timestamp" const FirstQueryGraphLimit = "first_query_graph_limit" const SecondQueryGraphLimit = "second_query_graph_limit" + +var TracesListViewDefaultSelectedColumns = []v3.AttributeKey{ + { + Key: "serviceName", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeTag, + IsColumn: true, + }, + { + Key: "name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeTag, + IsColumn: true, + }, + { + Key: "durationNano", + DataType: v3.AttributeKeyDataTypeArrayFloat64, + Type: v3.AttributeKeyTypeTag, + IsColumn: true, + }, + { + Key: "httpMethod", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeTag, + IsColumn: true, + }, + { + Key: "responseStatusCode", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeTag, + IsColumn: true, + }, +} diff --git a/pkg/query-service/rules/apiParams.go b/pkg/query-service/rules/apiParams.go index 300eac330f..6000ec280f 100644 --- a/pkg/query-service/rules/apiParams.go +++ b/pkg/query-service/rules/apiParams.go @@ -246,3 +246,25 @@ type GettableRule struct { UpdatedAt *time.Time `json:"updateAt"` UpdatedBy *string `json:"updateBy"` } + +type timeRange struct { + Start int64 `json:"start"` + End int64 `json:"end"` + PageSize int64 `json:"pageSize"` +} + +type builderQuery struct { + QueryData []v3.BuilderQuery `json:"queryData"` + QueryFormulas []string `json:"queryFormulas"` +} + +type urlShareableCompositeQuery struct { + QueryType string `json:"queryType"` + Builder builderQuery `json:"builder"` +} + +type Options struct { + MaxLines int `json:"maxLines"` + Format string `json:"format"` + SelectColumns []v3.AttributeKey `json:"selectColumns"` +} diff --git a/pkg/query-service/rules/thresholdRule.go b/pkg/query-service/rules/thresholdRule.go index 895026ffa7..c13008e040 100644 --- a/pkg/query-service/rules/thresholdRule.go +++ b/pkg/query-service/rules/thresholdRule.go @@ -3,8 +3,10 @@ package rules import ( "bytes" "context" + "encoding/json" "fmt" "math" + "net/url" "reflect" "regexp" "sort" @@ -60,6 +62,7 @@ type ThresholdRule struct { queryBuilder *queryBuilder.QueryBuilder opts ThresholdRuleOpts + typ string } type ThresholdRuleOpts struct { @@ -98,6 +101,7 @@ func NewThresholdRule( health: HealthUnknown, active: map[uint64]*Alert{}, opts: opts, + typ: p.AlertType, } if int64(t.evalWindow) == 0 { @@ -625,6 +629,210 @@ func (r *ThresholdRule) prepareBuilderQueries(ts time.Time) (map[string]string, return runQueries, err } +// The following function is used to prepare the where clause for the query +// `lbls` contains the key value pairs of the labels from the result of the query +// We iterate over the where clause and replace the labels with the actual values +// There are two cases: +// 1. The label is present in the where clause +// 2. The label is not present in the where clause +// +// Example for case 2: +// Latency by serviceName without any filter +// In this case, for each service with latency > threshold we send a notification +// The expectation will be that clicking on the related traces for service A, will +// take us to the traces page with the filter serviceName=A +// So for all the missing labels in the where clause, we add them as key = value +// +// Example for case 1: +// Severity text IN (WARN, ERROR) +// In this case, the Severity text will appear in the `lbls` if it were part of the group +// by clause, in which case we replace it with the actual value for the notification +// i.e Severity text = WARN +// If the Severity text is not part of the group by clause, then we add it as it is +func (r *ThresholdRule) fetchFilters(selectedQuery string, lbls labels.Labels) []v3.FilterItem { + var filterItems []v3.FilterItem + + added := make(map[string]struct{}) + + if r.ruleCondition.CompositeQuery.QueryType == v3.QueryTypeBuilder && + r.ruleCondition.CompositeQuery.BuilderQueries[selectedQuery] != nil && + r.ruleCondition.CompositeQuery.BuilderQueries[selectedQuery].Filters != nil { + + for _, item := range r.ruleCondition.CompositeQuery.BuilderQueries[selectedQuery].Filters.Items { + exists := false + for _, label := range lbls { + if item.Key.Key == label.Name { + // if the label is present in the where clause, replace it with key = value + filterItems = append(filterItems, v3.FilterItem{ + Key: item.Key, + Operator: v3.FilterOperatorEqual, + Value: label.Value, + }) + exists = true + added[label.Name] = struct{}{} + break + } + } + + if !exists { + // if the label is not present in the where clause, add it as it is + filterItems = append(filterItems, item) + } + } + } + + // add the labels which are not present in the where clause + for _, label := range lbls { + if _, ok := added[label.Name]; !ok { + filterItems = append(filterItems, v3.FilterItem{ + Key: v3.AttributeKey{Key: label.Name}, + Operator: v3.FilterOperatorEqual, + Value: label.Value, + }) + } + } + + return filterItems +} + +func (r *ThresholdRule) prepareLinksToLogs(ts time.Time, lbls labels.Labels) string { + selectedQuery := r.GetSelectedQuery() + + // TODO(srikanthccv): handle formula queries + if selectedQuery < "A" || selectedQuery > "Z" { + return "" + } + + // Logs list view expects time in milliseconds + tr := timeRange{ + Start: ts.Add(-time.Duration(r.evalWindow)).UnixMilli(), + End: ts.UnixMilli(), + PageSize: 100, + } + + options := Options{ + MaxLines: 2, + Format: "list", + SelectColumns: []v3.AttributeKey{}, + } + + period, _ := json.Marshal(tr) + urlEncodedTimeRange := url.QueryEscape(string(period)) + + filterItems := r.fetchFilters(selectedQuery, lbls) + urlData := urlShareableCompositeQuery{ + QueryType: string(v3.QueryTypeBuilder), + Builder: builderQuery{ + QueryData: []v3.BuilderQuery{ + { + DataSource: v3.DataSourceLogs, + QueryName: "A", + AggregateOperator: v3.AggregateOperatorNoOp, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Items: filterItems, + Operator: "AND", + }, + Expression: "A", + Disabled: false, + Having: []v3.Having{}, + StepInterval: 60, + OrderBy: []v3.OrderBy{ + { + ColumnName: "timestamp", + Order: "desc", + }, + }, + }, + }, + QueryFormulas: make([]string, 0), + }, + } + + data, _ := json.Marshal(urlData) + compositeQuery := url.QueryEscape(url.QueryEscape(string(data))) + + optionsData, _ := json.Marshal(options) + urlEncodedOptions := url.QueryEscape(string(optionsData)) + + return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions) +} + +func (r *ThresholdRule) prepareLinksToTraces(ts time.Time, lbls labels.Labels) string { + selectedQuery := r.GetSelectedQuery() + + // TODO(srikanthccv): handle formula queries + if selectedQuery < "A" || selectedQuery > "Z" { + return "" + } + + // Traces list view expects time in nanoseconds + tr := timeRange{ + Start: ts.Add(-time.Duration(r.evalWindow)).UnixNano(), + End: ts.UnixNano(), + PageSize: 100, + } + + options := Options{ + MaxLines: 2, + Format: "list", + SelectColumns: constants.TracesListViewDefaultSelectedColumns, + } + + period, _ := json.Marshal(tr) + urlEncodedTimeRange := url.QueryEscape(string(period)) + + filterItems := r.fetchFilters(selectedQuery, lbls) + urlData := urlShareableCompositeQuery{ + QueryType: string(v3.QueryTypeBuilder), + Builder: builderQuery{ + QueryData: []v3.BuilderQuery{ + { + DataSource: v3.DataSourceTraces, + QueryName: "A", + AggregateOperator: v3.AggregateOperatorNoOp, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Items: filterItems, + Operator: "AND", + }, + Expression: "A", + Disabled: false, + Having: []v3.Having{}, + StepInterval: 60, + OrderBy: []v3.OrderBy{ + { + ColumnName: "timestamp", + Order: "desc", + }, + }, + }, + }, + QueryFormulas: make([]string, 0), + }, + } + + data, _ := json.Marshal(urlData) + // We need to double encode the composite query to remain compatible with the UI + compositeQuery := url.QueryEscape(url.QueryEscape(string(data))) + + optionsData, _ := json.Marshal(options) + urlEncodedOptions := url.QueryEscape(string(optionsData)) + + return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions) +} + +func (r *ThresholdRule) hostFromSource() string { + parsedUrl, err := url.Parse(r.source) + if err != nil { + return "" + } + if parsedUrl.Port() != "" { + return fmt.Sprintf("%s:%s", parsedUrl.Hostname(), parsedUrl.Port()) + } + return parsedUrl.Hostname() +} + func (r *ThresholdRule) prepareClickhouseQueries(ts time.Time) (map[string]string, error) { queries := make(map[string]string) @@ -668,7 +876,7 @@ func (r *ThresholdRule) prepareClickhouseQueries(ts time.Time) (map[string]strin func (r *ThresholdRule) GetSelectedQuery() string { - // The acutal query string is not relevant here + // The actual query string is not relevant here // we just need to know the selected query var queries map[string]string @@ -846,6 +1054,18 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie lb.Set(labels.AlertRuleIdLabel, r.ID()) lb.Set(labels.RuleSourceLabel, r.GeneratorURL()) + if r.typ == "TRACES_BASED_ALERT" { + link := r.prepareLinksToTraces(ts, smpl.Metric) + if link != "" && r.hostFromSource() != "" { + lb.Set("See related traces: ", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)) + } + } else if r.typ == "LOGS_BASED_ALERT" { + link := r.prepareLinksToLogs(ts, smpl.Metric) + if link != "" && r.hostFromSource() != "" { + lb.Set("See related logs: ", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)) + } + } + annotations := make(labels.Labels, 0, len(r.annotations)) for _, a := range r.annotations { annotations = append(annotations, labels.Label{Name: normalizeLabelName(a.Name), Value: expand(a.Value)}) diff --git a/pkg/query-service/rules/thresholdRule_test.go b/pkg/query-service/rules/thresholdRule_test.go index 81cf97af1e..2b39084bec 100644 --- a/pkg/query-service/rules/thresholdRule_test.go +++ b/pkg/query-service/rules/thresholdRule_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "go.signoz.io/signoz/pkg/query-service/featureManager" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.signoz.io/signoz/pkg/query-service/utils/labels" ) func TestThresholdRuleCombinations(t *testing.T) { @@ -335,3 +336,87 @@ func TestNormalizeLabelName(t *testing.T) { assert.Equal(t, c.expected, normalizeLabelName(c.labelName)) } } + +func TestPrepareLinksToLogs(t *testing.T) { + postableRule := PostableRule{ + Alert: "Tricky Condition Tests", + AlertType: "LOGS_BASED_ALERT", + RuleType: RuleTypeThreshold, + EvalWindow: Duration(5 * time.Minute), + Frequency: Duration(1 * time.Minute), + RuleCondition: &RuleCondition{ + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + AggregateAttribute: v3.AttributeKey{ + Key: "", + }, + AggregateOperator: v3.AggregateOperatorNoOp, + DataSource: v3.DataSourceLogs, + Expression: "A", + }, + }, + }, + CompareOp: "4", // Not Equals + MatchType: "1", // Once + Target: &[]float64{0.0}[0], + SelectedQuery: "A", + }, + } + fm := featureManager.StartManager() + + rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{}, fm) + if err != nil { + assert.NoError(t, err) + } + + ts := time.UnixMilli(1705469040000) + + link := rule.prepareLinksToLogs(ts, labels.Labels{}) + assert.Contains(t, link, "&timeRange=%7B%22start%22%3A1705468740000%2C%22end%22%3A1705469040000%2C%22pageSize%22%3A100%7D&startTime=1705468740000&endTime=1705469040000") +} + +func TestPrepareLinksToTraces(t *testing.T) { + postableRule := PostableRule{ + Alert: "Links to traces test", + AlertType: "TRACES_BASED_ALERT", + RuleType: RuleTypeThreshold, + EvalWindow: Duration(5 * time.Minute), + Frequency: Duration(1 * time.Minute), + RuleCondition: &RuleCondition{ + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + AggregateAttribute: v3.AttributeKey{ + Key: "durationNano", + }, + AggregateOperator: v3.AggregateOperatorAvg, + DataSource: v3.DataSourceTraces, + Expression: "A", + }, + }, + }, + CompareOp: "4", // Not Equals + MatchType: "1", // Once + Target: &[]float64{0.0}[0], + SelectedQuery: "A", + }, + } + fm := featureManager.StartManager() + + rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{}, fm) + if err != nil { + assert.NoError(t, err) + } + + ts := time.UnixMilli(1705469040000) + + link := rule.prepareLinksToTraces(ts, labels.Labels{}) + assert.Contains(t, link, "&timeRange=%7B%22start%22%3A1705468740000000000%2C%22end%22%3A1705469040000000000%2C%22pageSize%22%3A100%7D&startTime=1705468740000000000&endTime=1705469040000000000") +}