diff --git a/go.mod b/go.mod index eeb6e05ec9..2b00916803 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module go.signoz.io/signoz -go 1.21 +go 1.21.3 require ( github.com/ClickHouse/clickhouse-go/v2 v2.15.0 - github.com/SigNoz/govaluate v0.0.0-20220522085550-d19c08c206cb + github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd github.com/SigNoz/signoz-otel-collector v0.88.9 github.com/SigNoz/zap_otlp/zap_otlp_encoder v0.0.0-20230822164844-1b861a431974 github.com/SigNoz/zap_otlp/zap_otlp_sync v0.0.0-20230822164844-1b861a431974 diff --git a/go.sum b/go.sum index 5d28bc873e..613a03adf6 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/SigNoz/govaluate v0.0.0-20220522085550-d19c08c206cb h1:bneLSKPf9YUSFmafKx32bynV6QrzViL/s+ZDvQxH1E4= -github.com/SigNoz/govaluate v0.0.0-20220522085550-d19c08c206cb/go.mod h1:JznGDNg9x1cujDKa22RaQOimOvvEfy3nxzDGd8XDgmA= +github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8= +github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc= github.com/SigNoz/prometheus v1.9.78 h1:bB3yuDrRzi/Mv00kWayR9DZbyjTuGfendSqISyDcXiY= github.com/SigNoz/prometheus v1.9.78/go.mod h1:MffmFu2qFILQrOHehx3D0XjYtaZMVfI+Ppeiv98x4Ww= github.com/SigNoz/signoz-otel-collector v0.88.9 h1:7bbJSXrSZcQsdEVVLsjsNXm/bWe9MhKu8qfXp8MlXQM= diff --git a/pkg/query-service/app/formula.go b/pkg/query-service/app/formula.go new file mode 100644 index 0000000000..2ead902172 --- /dev/null +++ b/pkg/query-service/app/formula.go @@ -0,0 +1,256 @@ +package app + +import ( + "fmt" + "math" + "sort" + + "github.com/SigNoz/govaluate" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +// Define the ExpressionEvalFunc type +type ExpressionEvalFunc func(*govaluate.EvaluableExpression, map[string]float64) float64 + +// Helper function to check if one label set is a subset of another +func isSubset(super, sub map[string]string) bool { + for k, v := range sub { + if val, ok := super[k]; !ok || val != v { + return false + } + } + return true +} + +// Function to find unique label sets +func findUniqueLabelSets(results []*v3.Result) []map[string]string { + allLabelSets := make([]map[string]string, 0) + // The size of the `results` small, It is the number of queries in the request + for _, result := range results { + // The size of the `result.Series` slice is usually small, It is the number of series in the query result. + // We will limit the number of series in the query result to order of 100-1000. + for _, series := range result.Series { + allLabelSets = append(allLabelSets, series.Labels) + } + } + + // sort the label sets by the number of labels in descending order + sort.Slice(allLabelSets, func(i, j int) bool { + return len(allLabelSets[i]) > len(allLabelSets[j]) + }) + + uniqueSets := make([]map[string]string, 0) + + for _, labelSet := range allLabelSets { + // If the label set is not a subset of any of the unique label sets, add it to the unique label sets + isUnique := true + for _, uniqueLabelSet := range uniqueSets { + if isSubset(uniqueLabelSet, labelSet) { + isUnique = false + break + } + } + if isUnique { + uniqueSets = append(uniqueSets, labelSet) + } + } + + return uniqueSets +} + +// Function to join series on timestamp and calculate new values +func joinAndCalculate(results []*v3.Result, uniqueLabelSet map[string]string, expression *govaluate.EvaluableExpression) (*v3.Series, error) { + + uniqueTimestamps := make(map[int64]struct{}) + // map[queryName]map[timestamp]value + seriesMap := make(map[string]map[int64]float64) + for _, result := range results { + var matchingSeries *v3.Series + // We try to find a series that matches the label set from the current query result + for _, series := range result.Series { + if isSubset(uniqueLabelSet, series.Labels) { + matchingSeries = series + break + } + } + + // Prepare the seriesMap for quick lookup during evaluation + // seriesMap[queryName][timestamp]value contains the value of the series with the given queryName at the given timestamp + if matchingSeries != nil { + for _, point := range matchingSeries.Points { + if _, ok := seriesMap[result.QueryName]; !ok { + seriesMap[result.QueryName] = make(map[int64]float64) + } + seriesMap[result.QueryName][point.Timestamp] = point.Value + uniqueTimestamps[point.Timestamp] = struct{}{} + } + } + } + + vars := expression.Vars() + var doesNotHaveAllVars bool + for _, v := range vars { + if _, ok := seriesMap[v]; !ok { + doesNotHaveAllVars = true + break + } + } + + // There is no series that matches the label set from all queries + // TODO: Does the lack of a series from one query mean that the result should be nil? + // Or should we interpret the series as having a value of 0 at all timestamps? + // The current behaviour with ClickHouse is to show no data + if doesNotHaveAllVars { + return nil, nil + } + + resultSeries := &v3.Series{ + Labels: uniqueLabelSet, + } + timestamps := make([]int64, 0) + for timestamp := range uniqueTimestamps { + timestamps = append(timestamps, timestamp) + } + sort.Slice(timestamps, func(i, j int) bool { + return timestamps[i] < timestamps[j] + }) + + for _, timestamp := range timestamps { + values := make(map[string]interface{}) + for queryName, series := range seriesMap { + values[queryName] = series[timestamp] + } + newValue, err := expression.Evaluate(values) + if err != nil { + return nil, err + } + + val, ok := newValue.(float64) + if !ok { + return nil, fmt.Errorf("expected float64, got %T", newValue) + } + + resultSeries.Points = append(resultSeries.Points, v3.Point{ + Timestamp: timestamp, + Value: val, + }) + } + return resultSeries, nil +} + +// Main function to process the Results +// A series can be "join"ed with other series if they have the same label set or one is a subset of the other. +// 1. Find all unique label sets +// 2. For each unique label set, find a series that matches the label set from each query result +// 3. Join the series on timestamp and calculate the new values +// 4. Return the new series +func processResults(results []*v3.Result, expression *govaluate.EvaluableExpression) (*v3.Result, error) { + uniqueLabelSets := findUniqueLabelSets(results) + newSeries := make([]*v3.Series, 0) + + for _, labelSet := range uniqueLabelSets { + series, err := joinAndCalculate(results, labelSet, expression) + if err != nil { + return nil, err + } + if series != nil { + newSeries = append(newSeries, series) + } + } + + return &v3.Result{ + Series: newSeries, + }, nil +} + +var SupportedFunctions = []string{"exp", "log", "ln", "exp2", "log2", "exp10", "log10", "sqrt", "cbrt", "erf", "erfc", "lgamma", "tgamma", "sin", "cos", "tan", "asin", "acos", "atan", "degrees", "radians"} + +func evalFuncs() map[string]govaluate.ExpressionFunction { + GoValuateFuncs := make(map[string]govaluate.ExpressionFunction) + // Returns e to the power of the given argument. + GoValuateFuncs["exp"] = func(args ...interface{}) (interface{}, error) { + return math.Exp(args[0].(float64)), nil + } + // Returns the natural logarithm of the given argument. + GoValuateFuncs["log"] = func(args ...interface{}) (interface{}, error) { + return math.Log(args[0].(float64)), nil + } + // Returns the natural logarithm of the given argument. + GoValuateFuncs["ln"] = func(args ...interface{}) (interface{}, error) { + return math.Log(args[0].(float64)), nil + } + // Returns the base 2 exponential of the given argument. + GoValuateFuncs["exp2"] = func(args ...interface{}) (interface{}, error) { + return math.Exp2(args[0].(float64)), nil + } + // Returns the base 2 logarithm of the given argument. + GoValuateFuncs["log2"] = func(args ...interface{}) (interface{}, error) { + return math.Log2(args[0].(float64)), nil + } + // Returns the base 10 exponential of the given argument. + GoValuateFuncs["exp10"] = func(args ...interface{}) (interface{}, error) { + return math.Pow10(int(args[0].(float64))), nil + } + // Returns the base 10 logarithm of the given argument. + GoValuateFuncs["log10"] = func(args ...interface{}) (interface{}, error) { + return math.Log10(args[0].(float64)), nil + } + // Returns the square root of the given argument. + GoValuateFuncs["sqrt"] = func(args ...interface{}) (interface{}, error) { + return math.Sqrt(args[0].(float64)), nil + } + // Returns the cube root of the given argument. + GoValuateFuncs["cbrt"] = func(args ...interface{}) (interface{}, error) { + return math.Cbrt(args[0].(float64)), nil + } + // Returns the error function of the given argument. + GoValuateFuncs["erf"] = func(args ...interface{}) (interface{}, error) { + return math.Erf(args[0].(float64)), nil + } + // Returns the complementary error function of the given argument. + GoValuateFuncs["erfc"] = func(args ...interface{}) (interface{}, error) { + return math.Erfc(args[0].(float64)), nil + } + // Returns the natural logarithm of the absolute value of the gamma function of the given argument. + GoValuateFuncs["lgamma"] = func(args ...interface{}) (interface{}, error) { + v, _ := math.Lgamma(args[0].(float64)) + return v, nil + } + // Returns the gamma function of the given argument. + GoValuateFuncs["tgamma"] = func(args ...interface{}) (interface{}, error) { + return math.Gamma(args[0].(float64)), nil + } + // Returns the sine of the given argument. + GoValuateFuncs["sin"] = func(args ...interface{}) (interface{}, error) { + return math.Sin(args[0].(float64)), nil + } + // Returns the cosine of the given argument. + GoValuateFuncs["cos"] = func(args ...interface{}) (interface{}, error) { + return math.Cos(args[0].(float64)), nil + } + // Returns the tangent of the given argument. + GoValuateFuncs["tan"] = func(args ...interface{}) (interface{}, error) { + return math.Tan(args[0].(float64)), nil + } + // Returns the arcsine of the given argument. + GoValuateFuncs["asin"] = func(args ...interface{}) (interface{}, error) { + return math.Asin(args[0].(float64)), nil + } + // Returns the arccosine of the given argument. + GoValuateFuncs["acos"] = func(args ...interface{}) (interface{}, error) { + return math.Acos(args[0].(float64)), nil + } + // Returns the arctangent of the given argument. + GoValuateFuncs["atan"] = func(args ...interface{}) (interface{}, error) { + return math.Atan(args[0].(float64)), nil + } + // Returns the argument converted from radians to degrees. + GoValuateFuncs["degrees"] = func(args ...interface{}) (interface{}, error) { + return args[0].(float64) * 180 / math.Pi, nil + } + // Returns the argument converted from degrees to radians. + GoValuateFuncs["radians"] = func(args ...interface{}) (interface{}, error) { + return args[0].(float64) * math.Pi / 180, nil + } + return GoValuateFuncs +} diff --git a/pkg/query-service/app/formula_test.go b/pkg/query-service/app/formula_test.go new file mode 100644 index 0000000000..17a073ae32 --- /dev/null +++ b/pkg/query-service/app/formula_test.go @@ -0,0 +1,1478 @@ +package app + +import ( + "math" + "reflect" + "testing" + + "github.com/SigNoz/govaluate" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +func TestFindUniqueLabelSets(t *testing.T) { + tests := []struct { + name string + result []*v3.Result + want []map[string]string + }{ + { + name: "test1", + result: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + "operation": "GET /api", + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "redis", + }, + }, + }, + }, + }, + want: []map[string]string{ + { + "service_name": "frontend", + "operation": "GET /api", + }, + { + "service_name": "redis", + }, + }, + }, + { + name: "test2", + result: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + "operation": "GET /api", + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + }, + }, + }, + }, + { + QueryName: "C", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "operation": "PUT /api", + }, + }, + }, + }, + { + QueryName: "D", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + "http_status": "200", + }, + }, + }, + }, + }, + want: []map[string]string{ + { + "service_name": "frontend", + "operation": "GET /api", + }, + { + "service_name": "frontend", + "http_status": "200", + }, + { + "operation": "PUT /api", + }, + }, + }, + { + name: "empty result", + result: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{}, + }, + { + QueryName: "B", + Series: []*v3.Series{}, + }, + }, + want: []map[string]string{}, + }, + { + name: "results with overlapping labels", + result: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + "operation": "GET /api", + }, + }, + { + Labels: map[string]string{ + "service_name": "redis", + "operation": "GET /api", + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "redis", + }, + }, + { + Labels: map[string]string{ + "service_name": "frontend", + }, + }, + }, + }, + }, + want: []map[string]string{ + { + "service_name": "frontend", + "operation": "GET /api", + }, + { + "service_name": "redis", + "operation": "GET /api", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findUniqueLabelSets(tt.result) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("findUniqueLabelSets() = %v, want %v\n", got, tt.want) + } + }) + } +} + +func TestProcessResults(t *testing.T) { + tests := []struct { + name string + results []*v3.Result + want *v3.Result + }{ + { + name: "test1", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + "operation": "GET /api", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 30, + }, + { + Timestamp: 3, + Value: 40, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expression, err := govaluate.NewEvaluableExpression("A + B") + if err != nil { + t.Errorf("Error parsing expression: %v", err) + } + got, err := processResults(tt.results, expression) + if err != nil { + t.Errorf("Error processing results: %v", err) + } + if len(got.Series) != len(tt.want.Series) { + t.Errorf("processResults(): number of sereis - got = %v, want %v", len(got.Series), len(tt.want.Series)) + } + + for i := range got.Series { + if len(got.Series[i].Points) != len(tt.want.Series[i].Points) { + t.Errorf("processResults(): number of points - got = %v, want %v", got, tt.want) + } + for j := range got.Series[i].Points { + if got.Series[i].Points[j].Value != tt.want.Series[i].Points[j].Value { + t.Errorf("processResults(): got = %v, want %v", got.Series[i].Points[j].Value, tt.want.Series[i].Points[j].Value) + } + } + } + }) + } +} + +func TestProcessResultsErrorRate(t *testing.T) { + tests := []struct { + name string + results []*v3.Result + want *v3.Result + }{ + { + name: "test1", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + }, + }, + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + }, + }, + { + Labels: map[string]string{ + "service_name": "route", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 2, + }, + { + Timestamp: 2, + Value: 45, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 6, + }, + { + Timestamp: 2, + Value: 9, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 0.5, + }, + { + Timestamp: 2, + Value: 0.2, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expression, err := govaluate.NewEvaluableExpression("B/A") + if err != nil { + t.Errorf("Error parsing expression: %v", err) + } + got, err := processResults(tt.results, expression) + if err != nil { + t.Errorf("Error processing results: %v", err) + } + if len(got.Series) != len(tt.want.Series) { + t.Errorf("processResults(): number of sereis - got = %v, want %v", len(got.Series), len(tt.want.Series)) + } + + for i := range got.Series { + if len(got.Series[i].Points) != len(tt.want.Series[i].Points) { + t.Errorf("processResults(): number of points - got = %v, want %v", got, tt.want) + } + for j := range got.Series[i].Points { + if got.Series[i].Points[j].Value != tt.want.Series[i].Points[j].Value { + t.Errorf("processResults(): got = %v, want %v", got.Series[i].Points[j].Value, tt.want.Series[i].Points[j].Value) + } + } + } + }) + } +} + +func TestFormula(t *testing.T) { + tests := []struct { + name string + expression string + results []*v3.Result + want *v3.Result + }{ + { + name: "No group keys on the left side", + expression: "B/A", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + }, + }, + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{}, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "service_name": "frontend", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 2.2, + }, + { + Timestamp: 2, + Value: 3.25, + }, + }, + }, + { + Labels: map[string]string{ + "service_name": "redis", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 1.8333333333333333, + }, + { + Timestamp: 2, + Value: 1.4444444444444444, + }, + }, + }, + }, + }, + }, + { + name: "No group keys on the right side", + expression: "A/B", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{}, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 0.45454545454545453, + }, + { + Timestamp: 2, + Value: 0.3076923076923077, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 0.5454545454545454, + }, + { + Timestamp: 2, + Value: 0.6923076923076923, + }, + }, + }, + }, + }, + }, + { + name: "Group keys on both sides are the same", + expression: "A/B", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: float64(10) / float64(22), + }, + { + Timestamp: 2, + Value: 0.3076923076923077, + }, + { + Timestamp: 3, + Value: 0, + }, + { + Timestamp: 4, + Value: 1, + }, + { + Timestamp: 5, + Value: 1, + }, + { + Timestamp: 7, + Value: math.Inf(1), + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 0.5454545454545454, + }, + { + Timestamp: 2, + Value: 0.6923076923076923, + }, + { + Timestamp: 3, + Value: math.Inf(1), + }, + { + Timestamp: 4, + Value: 1, + }, + { + Timestamp: 5, + Value: 1, + }, + }, + }, + }, + }, + }, + { + name: "Group keys on both sides are same but different values", + expression: "A/B", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "not_running_chalamet", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "busy", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + }, + want: &v3.Result{}, + }, + { + name: "Group keys on both sides are overlapping but do not match exactly", + expression: "A/B", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "os.type": "linux", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + { + Labels: map[string]string{ + "os.type": "windows", + "state": "busy", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + }, + want: &v3.Result{}, + }, + { + name: "Group keys on the left side are a superset of the right side", + expression: "A/B", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "state": "running", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + { + Labels: map[string]string{ + "state": "busy", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: float64(10) / float64(22), + }, + { + Timestamp: 2, + Value: 0.3076923076923077, + }, + { + Timestamp: 3, + Value: 0, + }, + { + Timestamp: 4, + Value: 1, + }, + { + Timestamp: 5, + Value: 1, + }, + { + Timestamp: 7, + Value: math.Inf(1), + }, + }, + }, + }, + }, + }, + { + name: "Group keys are subsets, A is a subset of B and their result is a subset of C", + expression: "A/B + C", + results: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 22, + }, + { + Timestamp: 2, + Value: 65, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + { + QueryName: "C", + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10, + }, + { + Timestamp: 2, + Value: 20, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + { + Timestamp: 7, + Value: 70, + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12, + }, + { + Timestamp: 2, + Value: 45, + }, + { + Timestamp: 3, + Value: 30, + }, + { + Timestamp: 4, + Value: 40, + }, + { + Timestamp: 5, + Value: 50, + }, + }, + }, + }, + }, + }, + want: &v3.Result{ + Series: []*v3.Series{ + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-1", + "state": "running", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 10.45454545454545453, + }, + { + Timestamp: 2, + Value: 20.3076923076923077, + }, + { + Timestamp: 3, + Value: 0, + }, + { + Timestamp: 4, + Value: 41, + }, + { + Timestamp: 5, + Value: 51, + }, + { + Timestamp: 7, + Value: math.Inf(1), + }, + }, + }, + { + Labels: map[string]string{ + "host_name": "ip-10-420-69-2", + "state": "idle", + "os.type": "linux", + }, + Points: []v3.Point{ + { + Timestamp: 1, + Value: 12.5454545454545454, + }, + { + Timestamp: 2, + Value: 45.6923076923076923, + }, + { + Timestamp: 3, + Value: math.Inf(1), + }, + { + Timestamp: 4, + Value: 41, + }, + { + Timestamp: 5, + Value: 51, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expression, err := govaluate.NewEvaluableExpression(tt.expression) + if err != nil { + t.Errorf("Error parsing expression: %v", err) + } + got, err := processResults(tt.results, expression) + if err != nil { + t.Errorf("Error processing results: %v", err) + } + if len(got.Series) != len(tt.want.Series) { + t.Errorf("processResults(): number of series - got = %v, want %v", len(got.Series), len(tt.want.Series)) + } + + for i := range got.Series { + if len(got.Series[i].Points) != len(tt.want.Series[i].Points) { + t.Errorf("processResults(): number of points - got = %v, want %v", len(got.Series[i].Points), len(tt.want.Series[i].Points)) + } + for j := range got.Series[i].Points { + if got.Series[i].Points[j].Value != tt.want.Series[i].Points[j].Value { + t.Errorf("processResults(): got = %v, want %v", got.Series[i].Points[j].Value, tt.want.Series[i].Points[j].Value) + } + } + } + }) + } +} diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 57962fa3ac..e548d9e6b4 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -14,6 +14,7 @@ import ( "text/template" "time" + "github.com/SigNoz/govaluate" "github.com/gorilla/mux" jsoniter "github.com/json-iterator/go" _ "github.com/mattn/go-sqlite3" @@ -3223,7 +3224,13 @@ func (aH *APIHandler) queryRangeV4(ctx context.Context, queryRangeParams *v3.Que } if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { - postProcessResult(result, queryRangeParams) + result, err = postProcessResult(result, queryRangeParams) + } + + if err != nil { + apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err} + RespondError(w, apiErrObj, errQuriesByName) + return } resp := v3.QueryRangeResponse{ @@ -3260,7 +3267,7 @@ func (aH *APIHandler) QueryRangeV4(w http.ResponseWriter, r *http.Request) { // Much of this work can be done in the ClickHouse query, but we decided to do it here because: // 1. Effective use of caching // 2. Easier to add new functions -func postProcessResult(result []*v3.Result, queryRangeParams *v3.QueryRangeParamsV3) { +func postProcessResult(result []*v3.Result, queryRangeParams *v3.QueryRangeParamsV3) ([]*v3.Result, error) { // Having clause is not part of the clickhouse query, so we need to apply it here // It's not included in the query because it doesn't work nicely with caching // With this change, if you have a query with a having clause, and then you change the having clause @@ -3281,6 +3288,27 @@ func postProcessResult(result []*v3.Result, queryRangeParams *v3.QueryRangeParam applyReduceTo(result, queryRangeParams) // We apply the functions here it's easier to add new functions applyFunctions(result, queryRangeParams) + + for _, query := range queryRangeParams.CompositeQuery.BuilderQueries { + // The way we distinguish between a formula and a query is by checking if the expression + // is the same as the query name + // TODO(srikanthccv): Update the UI to send a flag to distinguish between a formula and a query + if query.Expression != query.QueryName { + expression, err := govaluate.NewEvaluableExpressionWithFunctions(query.Expression, evalFuncs()) + // This shouldn't happen here, because it should have been caught earlier in validation + if err != nil { + zap.S().Errorf("error in expression: %s", err.Error()) + return nil, err + } + formulaResult, err := processResults(result, expression) + if err != nil { + zap.S().Errorf("error in expression: %s", err.Error()) + return nil, err + } + result = append(result, formulaResult) + } + } + return result, nil } // applyFunctions applies functions for each query in the composite query diff --git a/pkg/query-service/app/parser.go b/pkg/query-service/app/parser.go index 96905dc1d5..36d2f9bc1f 100644 --- a/pkg/query-service/app/parser.go +++ b/pkg/query-service/app/parser.go @@ -985,6 +985,41 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { for _, query := range queryRangeParams.CompositeQuery.BuilderQueries { + // Formula query + if query.QueryName != query.Expression { + expression, err := govaluate.NewEvaluableExpressionWithFunctions(query.Expression, evalFuncs()) + if err != nil { + return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err} + } + + // get the group keys for the vars + groupKeys := make(map[string][]string) + for _, v := range expression.Vars() { + if varQuery, ok := queryRangeParams.CompositeQuery.BuilderQueries[v]; ok { + groupKeys[v] = []string{} + for _, key := range varQuery.GroupBy { + groupKeys[v] = append(groupKeys[v], key.Key) + } + } else { + return nil, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("unknown variable %s", v)} + } + } + + params := make(map[string]interface{}) + for k, v := range groupKeys { + params[k] = v + } + + can, _, err := expression.CanJoin(params) + if err != nil { + return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err} + } + + if !can { + return nil, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("cannot join the given group keys")} + } + } + if query.Filters == nil || len(query.Filters.Items) == 0 { continue } diff --git a/pkg/query-service/app/parser_test.go b/pkg/query-service/app/parser_test.go index 65ea226909..8b172027a4 100644 --- a/pkg/query-service/app/parser_test.go +++ b/pkg/query-service/app/parser_test.go @@ -3,7 +3,6 @@ package app import ( "bytes" "encoding/json" - "fmt" "net/http" "net/http/httptest" "strings" @@ -749,7 +748,6 @@ func TestParseQueryRangeParamsDashboardVarsSubstitution(t *testing.T) { require.Error(t, apiErr) require.Contains(t, apiErr.Error(), tc.errMsg) } else { - fmt.Println(apiErr) require.Nil(t, apiErr) require.Equal(t, parsedQueryRangeParams.CompositeQuery.BuilderQueries["A"].Filters.Items[0].Value, tc.expectedValue[0]) require.Equal(t, parsedQueryRangeParams.CompositeQuery.BuilderQueries["A"].Filters.Items[1].Value, tc.expectedValue[1]) @@ -862,3 +860,368 @@ func TestParseQueryRangeParamsPromQLVars(t *testing.T) { }) } } + +func TestQueryRangeFormula(t *testing.T) { + reqCases := []struct { + desc string + compositeQuery v3.CompositeQuery + variables map[string]interface{} + expectErr bool + errMsg string + }{ + { + desc: "disjoint group by keys", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}}, + + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "operation_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "B/A", + }, + }, + }, + expectErr: true, + errMsg: "Group keys must match or be a subset of the other but found left: [operation_name], right: [service_name]", + }, + { + desc: "identical single group by key", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "B/A", + }, + }, + }, + expectErr: false, + }, + { + desc: "identical multiple group by keys", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "B/A", + }, + }, + }, + expectErr: false, + }, + { + desc: "identical multiple group by keys with different order", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "operation_name"}, {Key: "service_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "B/A", + }, + }, + }, + expectErr: false, + }, + { + desc: "subset group by keys", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "A/B", + }, + }, + }, + expectErr: false, + }, + { + desc: "empty keys on one side", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "A/B", + }, + }, + }, + expectErr: false, + }, + { + desc: "empty keys on both sides", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "A/B", + }, + }, + }, + expectErr: false, + }, + { + desc: "multiple group by keys with partial overlap", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "operation_name"}, {Key: "status_code"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "A/B", + }, + }, + }, + expectErr: true, + errMsg: "Group keys must match or be a subset of the other but found left: [service_name operation_name], right: [operation_name status_code]", + }, + { + desc: "Nested Expressions with Matching Keys - Testing expressions that involve operations (e.g., addition, division) with series whose keys match or are subsets.", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "B", + }, + "F1": { + QueryName: "F1", + Expression: "A + B", + }, + }, + }, + expectErr: false, + }, + { + desc: "Nested Expressions with Matching Keys - Testing expressions that involve operations (e.g., addition, division) with series whose keys match or are subsets.", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}}, + Expression: "A", + }, + "B": { + QueryName: "B", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}}, + Expression: "B", + }, + "C": { + QueryName: "C", + DataSource: v3.DataSourceMetrics, + AggregateOperator: v3.AggregateOperatorSum, + AggregateAttribute: v3.AttributeKey{Key: "signoz_calls_total"}, + GroupBy: []v3.AttributeKey{{Key: "service_name"}, {Key: "operation_name"}, {Key: "status_code"}}, + Expression: "C", + }, + "F1": { + QueryName: "F1", + Expression: "C/(A + B)", + }, + }, + }, + expectErr: false, + }, + { + desc: "Unknow variable in expression", + compositeQuery: v3.CompositeQuery{ + PanelType: v3.PanelTypeGraph, + QueryType: v3.QueryTypeBuilder, + BuilderQueries: map[string]*v3.BuilderQuery{ + "F1": { + QueryName: "F1", + Expression: "A + B", + }, + }, + }, + expectErr: true, + errMsg: "unknown variable", + }, + } + + for _, tc := range reqCases { + t.Run(tc.desc, func(t *testing.T) { + + queryRangeParams := &v3.QueryRangeParamsV3{ + Start: time.Now().Add(-time.Hour).UnixMilli(), + End: time.Now().UnixMilli(), + Step: time.Minute.Microseconds(), + CompositeQuery: &tc.compositeQuery, + Variables: tc.variables, + } + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(queryRangeParams) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v4/query_range", body) + + _, apiErr := ParseQueryRangeParams(req) + if tc.expectErr { + require.Error(t, apiErr) + require.Contains(t, apiErr.Error(), tc.errMsg) + } else { + if apiErr != nil { + if apiErr.Err != nil { + t.Fatalf("unexpected error for case: %s - %v", tc.desc, apiErr.Err) + } + } + require.Nil(t, apiErr) + } + }) + } +}