feat: add alerts to explorer link in notification (#4446)

This commit is contained in:
Srikanth Chekuri 2024-02-02 21:16:14 +05:30 committed by GitHub
parent f5b5a9a657
commit 00b111fbe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 361 additions and 1 deletions

View File

@ -352,3 +352,36 @@ const TIMESTAMP = "timestamp"
const FirstQueryGraphLimit = "first_query_graph_limit" const FirstQueryGraphLimit = "first_query_graph_limit"
const SecondQueryGraphLimit = "second_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,
},
}

View File

@ -246,3 +246,25 @@ type GettableRule struct {
UpdatedAt *time.Time `json:"updateAt"` UpdatedAt *time.Time `json:"updateAt"`
UpdatedBy *string `json:"updateBy"` 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"`
}

View File

@ -3,8 +3,10 @@ package rules
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"math" "math"
"net/url"
"reflect" "reflect"
"regexp" "regexp"
"sort" "sort"
@ -60,6 +62,7 @@ type ThresholdRule struct {
queryBuilder *queryBuilder.QueryBuilder queryBuilder *queryBuilder.QueryBuilder
opts ThresholdRuleOpts opts ThresholdRuleOpts
typ string
} }
type ThresholdRuleOpts struct { type ThresholdRuleOpts struct {
@ -98,6 +101,7 @@ func NewThresholdRule(
health: HealthUnknown, health: HealthUnknown,
active: map[uint64]*Alert{}, active: map[uint64]*Alert{},
opts: opts, opts: opts,
typ: p.AlertType,
} }
if int64(t.evalWindow) == 0 { if int64(t.evalWindow) == 0 {
@ -625,6 +629,210 @@ func (r *ThresholdRule) prepareBuilderQueries(ts time.Time) (map[string]string,
return runQueries, err 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) { func (r *ThresholdRule) prepareClickhouseQueries(ts time.Time) (map[string]string, error) {
queries := make(map[string]string) queries := make(map[string]string)
@ -668,7 +876,7 @@ func (r *ThresholdRule) prepareClickhouseQueries(ts time.Time) (map[string]strin
func (r *ThresholdRule) GetSelectedQuery() string { 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 // we just need to know the selected query
var queries map[string]string 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.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL()) 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)) annotations := make(labels.Labels, 0, len(r.annotations))
for _, a := range r.annotations { for _, a := range r.annotations {
annotations = append(annotations, labels.Label{Name: normalizeLabelName(a.Name), Value: expand(a.Value)}) annotations = append(annotations, labels.Label{Name: normalizeLabelName(a.Name), Value: expand(a.Value)})

View File

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.signoz.io/signoz/pkg/query-service/featureManager" "go.signoz.io/signoz/pkg/query-service/featureManager"
v3 "go.signoz.io/signoz/pkg/query-service/model/v3" v3 "go.signoz.io/signoz/pkg/query-service/model/v3"
"go.signoz.io/signoz/pkg/query-service/utils/labels"
) )
func TestThresholdRuleCombinations(t *testing.T) { func TestThresholdRuleCombinations(t *testing.T) {
@ -335,3 +336,87 @@ func TestNormalizeLabelName(t *testing.T) {
assert.Equal(t, c.expected, normalizeLabelName(c.labelName)) 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")
}