diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/series_limit.go b/pkg/types/querybuildertypes/querybuildertypesv5/series_limit.go new file mode 100644 index 0000000000..48f337f69c --- /dev/null +++ b/pkg/types/querybuildertypes/querybuildertypesv5/series_limit.go @@ -0,0 +1,170 @@ +package querybuildertypesv5 + +import ( + "math" + "sort" + "strconv" + "strings" + + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +const ( + DefaultOrderByKey = "__result" +) + +// ApplyLimit applies limit and ordering to a list of time series +// This function sorts the series based on the provided order criteria and applies the limit +func ApplySeriesLimit(series []*TimeSeries, orderBy []OrderBy, limit int) []*TimeSeries { + if len(series) == 0 { + return series + } + + // If no orderBy is specified, sort by value in descending order + effectiveOrderBy := orderBy + if len(effectiveOrderBy) == 0 { + effectiveOrderBy = []OrderBy{ + { + Key: OrderByKey{ + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: DefaultOrderByKey, + FieldDataType: telemetrytypes.FieldDataTypeFloat64, + }, + }, + Direction: OrderDirectionDesc, + }, + } + } + + // Sort the series based on the order criteria + sort.SliceStable(series, func(i, j int) bool { + return compareSeries(series[i], series[j], effectiveOrderBy) + }) + + // Apply limit if specified + if limit > 0 && len(series) > limit { + return series[:limit] + } + + return series +} + +// compareSeries compares two time series based on the order criteria +// Returns true if series i should come before series j +func compareSeries(seriesI, seriesJ *TimeSeries, orderBy []OrderBy) bool { + for _, order := range orderBy { + columnName := order.Key.Name + direction := order.Direction + + if columnName == DefaultOrderByKey { + // Sort based on aggregated values + valueI := calculateSeriesValue(seriesI) + valueJ := calculateSeriesValue(seriesJ) + + if valueI != valueJ { + if direction == OrderDirectionAsc { + return valueI < valueJ + } else { // desc + return valueI > valueJ + } + } + } else { + // Sort based on labels + labelI, existsI := findLabelValue(seriesI, columnName) + labelJ, existsJ := findLabelValue(seriesJ, columnName) + + if existsI != existsJ { + // Handle missing labels - non-existent labels come first + return !existsI + } + + if existsI && existsJ { + comparison := strings.Compare(labelI, labelJ) + if comparison != 0 { + if direction == OrderDirectionAsc { + return comparison < 0 + } else { // desc + return comparison > 0 + } + } + } + } + } + + // If all order criteria are equal, preserve original order + return false +} + +// calculateSeriesValue calculates the representative value for a time series +// For single-point series (like table queries), returns that value +// For multi-point series, returns the average of non-NaN/non-Inf values +func calculateSeriesValue(series *TimeSeries) float64 { + if len(series.Values) == 0 { + return 0.0 + } + + // For single-point series, return that value directly + if len(series.Values) == 1 { + value := series.Values[0].Value + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0.0 + } + return value + } + + // For multi-point series, calculate average of valid values + var sum float64 + var count float64 + + for _, point := range series.Values { + if math.IsNaN(point.Value) || math.IsInf(point.Value, 0) { + continue + } + sum += point.Value + count++ + } + + // Avoid division by zero + if count == 0 { + return 0.0 + } + + return sum / count +} + +// findLabelValue finds the value of a label with the given key in a time series +// Returns the label value and whether it was found +func findLabelValue(series *TimeSeries, key string) (string, bool) { + for _, label := range series.Labels { + if label.Key.Name == key { + // Convert label value to string + if strVal, ok := label.Value.(string); ok { + return strVal, true + } + // Handle non-string values by converting to string + return convertValueToString(label.Value), true + } + } + return "", false +} + +// convertValueToString converts various types to string for comparison +func convertValueToString(value any) string { + switch v := value.(type) { + case string: + return v + case int: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + if v { + return "true" + } + return "false" + default: + return "" + } +} diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/series_limit_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/series_limit_test.go new file mode 100644 index 0000000000..918bcfdb08 --- /dev/null +++ b/pkg/types/querybuildertypes/querybuildertypesv5/series_limit_test.go @@ -0,0 +1,234 @@ +package querybuildertypesv5 + +import ( + "testing" + + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/stretchr/testify/assert" +) + +func TestApplySeriesLimit(t *testing.T) { + t.Run("Sort by value with limit", func(t *testing.T) { + // Create test series with different values + series := []*TimeSeries{ + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "service-a", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 10.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "service-b", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 50.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "service-c", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 30.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "service-d", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 20.0}, + }, + }, + } + + // Sort by value descending with limit of 2 + orderBy := []OrderBy{ + { + Key: OrderByKey{ + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: DefaultOrderByKey, + FieldDataType: telemetrytypes.FieldDataTypeFloat64, + }, + }, + Direction: OrderDirectionDesc, + }, + } + + result := ApplySeriesLimit(series, orderBy, 2) + + // Should return top 2 series by value: service-b (50.0), service-c (30.0) + assert.Len(t, result, 2) + + // First series should be service-b with value 50.0 + assert.Equal(t, "service-b", result[0].Labels[0].Value) + assert.Equal(t, 50.0, result[0].Values[0].Value) + + // Second series should be service-c with value 30.0 + assert.Equal(t, "service-c", result[1].Labels[0].Value) + assert.Equal(t, 30.0, result[1].Values[0].Value) + }) + + t.Run("Sort by labels with two keys", func(t *testing.T) { + // Create test series with different label combinations + series := []*TimeSeries{ + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "backend", + }, + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "environment", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "prod", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 10.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "frontend", + }, + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "environment", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "dev", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 20.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "backend", + }, + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "environment", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "dev", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 30.0}, + }, + }, + { + Labels: []*Label{ + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "frontend", + }, + { + Key: telemetrytypes.TelemetryFieldKey{ + Name: "environment", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + Value: "prod", + }, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 40.0}, + }, + }, + } + + // Sort by service (asc) then by environment (desc) with limit of 3 + orderBy := []OrderBy{ + { + Key: OrderByKey{ + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: "service", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + Direction: OrderDirectionAsc, + }, + { + Key: OrderByKey{ + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: "environment", + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + Direction: OrderDirectionDesc, + }, + } + + result := ApplySeriesLimit(series, orderBy, 3) + + // Should return 3 series sorted by service (asc), then environment (desc) + // Expected order: + // 1. backend + prod + // 2. backend + dev + // 3. frontend + prod + assert.Len(t, result, 3) + + // First: backend + prod + assert.Equal(t, "backend", result[0].Labels[0].Value) + assert.Equal(t, "prod", result[0].Labels[1].Value) + assert.Equal(t, 10.0, result[0].Values[0].Value) + + // Second: backend + dev + assert.Equal(t, "backend", result[1].Labels[0].Value) + assert.Equal(t, "dev", result[1].Labels[1].Value) + assert.Equal(t, 30.0, result[1].Values[0].Value) + + // Third: frontend + prod + assert.Equal(t, "frontend", result[2].Labels[0].Value) + assert.Equal(t, "prod", result[2].Labels[1].Value) + assert.Equal(t, 40.0, result[2].Values[0].Value) + }) +}