diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/resp.go b/pkg/types/querybuildertypes/querybuildertypesv5/resp.go index 544ccebddb3e..1296f086cae4 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/resp.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/resp.go @@ -1,7 +1,10 @@ package querybuildertypesv5 import ( + "encoding/json" "fmt" + "math" + "reflect" "slices" "strings" "time" @@ -144,3 +147,137 @@ type RawRow struct { Timestamp time.Time `json:"timestamp"` Data map[string]*any `json:"data"` } + +func sanitizeValue(v any) any { + if v == nil { + return nil + } + + if f, ok := v.(float64); ok { + if math.IsNaN(f) { + return "NaN" + } else if math.IsInf(f, 1) { + return "Inf" + } else if math.IsInf(f, -1) { + return "-Inf" + } + return f + } + + if f, ok := v.(float32); ok { + f64 := float64(f) + if math.IsNaN(f64) { + return "NaN" + } else if math.IsInf(f64, 1) { + return "Inf" + } else if math.IsInf(f64, -1) { + return "-Inf" + } + return f + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Slice: + result := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + result[i] = sanitizeValue(rv.Index(i).Interface()) + } + return result + case reflect.Map: + result := make(map[string]any) + for _, key := range rv.MapKeys() { + keyStr := key.String() + result[keyStr] = sanitizeValue(rv.MapIndex(key).Interface()) + } + return result + case reflect.Ptr: + if rv.IsNil() { + return nil + } + return sanitizeValue(rv.Elem().Interface()) + case reflect.Struct: + return v + default: + return v + } +} + +func (q QueryRangeResponse) MarshalJSON() ([]byte, error) { + type Alias QueryRangeResponse + return json.Marshal(&struct { + *Alias + Data any `json:"data"` + }{ + Alias: (*Alias)(&q), + Data: sanitizeValue(q.Data), + }) +} + +func (s ScalarData) MarshalJSON() ([]byte, error) { + type Alias ScalarData + sanitizedData := make([][]any, len(s.Data)) + for i, row := range s.Data { + sanitizedData[i] = make([]any, len(row)) + for j, val := range row { + sanitizedData[i][j] = sanitizeValue(val) + } + } + + return json.Marshal(&struct { + *Alias + Data [][]any `json:"data"` + }{ + Alias: (*Alias)(&s), + Data: sanitizedData, + }) +} + +func (r RawRow) MarshalJSON() ([]byte, error) { + type Alias RawRow + sanitizedData := make(map[string]*any) + for k, v := range r.Data { + if v != nil { + sanitized := sanitizeValue(*v) + sanitizedData[k] = &sanitized + } else { + sanitizedData[k] = nil + } + } + + return json.Marshal(&struct { + *Alias + Data map[string]*any `json:"data"` + }{ + Alias: (*Alias)(&r), + Data: sanitizedData, + }) +} + +func (t TimeSeriesValue) MarshalJSON() ([]byte, error) { + type Alias TimeSeriesValue + + var sanitizedValues any + if t.Values != nil { + sanitizedValues = sanitizeValue(t.Values) + // If original was empty slice, ensure we return empty slice not nil + if len(t.Values) == 0 { + sanitizedValues = []any{} + } + } + + return json.Marshal(&struct { + *Alias + Value any `json:"value"` + Values any `json:"values,omitempty"` + }{ + Alias: (*Alias)(&t), + Value: sanitizeValue(t.Value), + Values: sanitizedValues, + }) +} + +func (r RawData) MarshalJSON() ([]byte, error) { + type Alias RawData + return json.Marshal((*Alias)(&r)) +} diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go new file mode 100644 index 000000000000..d55dbac25368 --- /dev/null +++ b/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go @@ -0,0 +1,310 @@ +package querybuildertypesv5 + +import ( + "encoding/json" + "math" + "strings" + "testing" + + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +func TestTimeSeriesValue_MarshalJSON(t *testing.T) { + tests := []struct { + name string + value TimeSeriesValue + expected string + }{ + { + name: "normal value", + value: TimeSeriesValue{ + Timestamp: 1234567890, + Value: 42.5, + }, + expected: `{"timestamp":1234567890,"value":42.5}`, + }, + { + name: "NaN value", + value: TimeSeriesValue{ + Timestamp: 1234567890, + Value: math.NaN(), + }, + expected: `{"timestamp":1234567890,"value":"NaN"}`, + }, + { + name: "positive infinity", + value: TimeSeriesValue{ + Timestamp: 1234567890, + Value: math.Inf(1), + }, + expected: `{"timestamp":1234567890,"value":"Inf"}`, + }, + { + name: "negative infinity", + value: TimeSeriesValue{ + Timestamp: 1234567890, + Value: math.Inf(-1), + }, + expected: `{"timestamp":1234567890,"value":"-Inf"}`, + }, + { + name: "values array with NaN", + value: TimeSeriesValue{ + Timestamp: 1234567890, + Value: 1.0, + Values: []float64{1.0, math.NaN(), 3.0, math.Inf(1)}, + }, + expected: `{"timestamp":1234567890,"value":1,"values":[1,"NaN",3,"Inf"]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.value) + if err != nil { + t.Errorf("MarshalJSON() error = %v", err) + return + } + if string(got) != tt.expected { + t.Errorf("MarshalJSON() = %v, want %v", string(got), tt.expected) + } + }) + } +} + +func TestTimeSeries_MarshalJSON_WithNaN(t *testing.T) { + ts := &TimeSeries{ + Labels: []*Label{ + {Key: telemetrytypes.TelemetryFieldKey{Name: "test"}, Value: "value"}, + }, + Values: []*TimeSeriesValue{ + {Timestamp: 1000, Value: 1.0}, + {Timestamp: 2000, Value: math.NaN()}, + {Timestamp: 3000, Value: math.Inf(1)}, + }, + } + + data, err := json.Marshal(ts) + if err != nil { + t.Fatalf("Failed to marshal TimeSeries: %v", err) + } + + // Verify the JSON is valid by unmarshaling into a generic structure + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("Failed to unmarshal result: %v", err) + } + + // Just verify that the JSON contains the expected string representations + jsonStr := string(data) + if !strings.Contains(jsonStr, `"value":"NaN"`) { + t.Errorf("Expected JSON to contain NaN as string, got %s", jsonStr) + } + if !strings.Contains(jsonStr, `"value":"Inf"`) { + t.Errorf("Expected JSON to contain Inf as string, got %s", jsonStr) + } +} + +func TestScalarData_MarshalJSON(t *testing.T) { + tests := []struct { + name string + data ScalarData + expected string + }{ + { + name: "normal scalar data", + data: ScalarData{ + QueryName: "test_query", + Columns: []*ColumnDescriptor{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "value"}, + QueryName: "test_query", + AggregationIndex: 0, + Type: ColumnTypeAggregation, + }, + }, + Data: [][]any{ + {1.0, 2.0, 3.0}, + {4.0, 5.0, 6.0}, + }, + }, + expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`, + }, + { + name: "scalar data with NaN", + data: ScalarData{ + QueryName: "test_query", + Columns: []*ColumnDescriptor{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "value"}, + QueryName: "test_query", + AggregationIndex: 0, + Type: ColumnTypeAggregation, + }, + }, + Data: [][]any{ + {1.0, math.NaN(), 3.0}, + {math.Inf(1), 5.0, math.Inf(-1)}, + }, + }, + expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`, + }, + { + name: "scalar data with mixed types", + data: ScalarData{ + QueryName: "test_query", + Columns: []*ColumnDescriptor{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "mixed"}, + QueryName: "test_query", + AggregationIndex: 0, + Type: ColumnTypeAggregation, + }, + }, + Data: [][]any{ + {"string", 42, math.NaN(), true}, + {nil, math.Inf(1), 3.14, false}, + }, + }, + expected: `{"queryName":"test_query","columns":[{"name":"mixed","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`, + }, + { + name: "scalar data with nested structures", + data: ScalarData{ + QueryName: "test_query", + Columns: []*ColumnDescriptor{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "nested"}, + QueryName: "test_query", + AggregationIndex: 0, + Type: ColumnTypeAggregation, + }, + }, + Data: [][]any{ + { + map[string]any{"value": math.NaN(), "count": 10}, + []any{1.0, math.Inf(1), 3.0}, + }, + }, + }, + expected: `{"queryName":"test_query","columns":[{"name":"nested","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`, + }, + { + name: "empty scalar data", + data: ScalarData{ + QueryName: "empty_query", + Columns: []*ColumnDescriptor{}, + Data: [][]any{}, + }, + expected: `{"queryName":"empty_query","columns":[],"data":[]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.data) + if err != nil { + t.Errorf("MarshalJSON() error = %v", err) + return + } + if string(got) != tt.expected { + t.Errorf("MarshalJSON() = %v, want %v", string(got), tt.expected) + } + }) + } +} + +func TestSanitizeValue(t *testing.T) { + tests := []struct { + name string + input interface{} + expected interface{} + }{ + { + name: "nil value", + input: nil, + expected: nil, + }, + { + name: "normal float64", + input: 42.5, + expected: 42.5, + }, + { + name: "NaN float64", + input: math.NaN(), + expected: "NaN", + }, + { + name: "positive infinity float64", + input: math.Inf(1), + expected: "Inf", + }, + { + name: "negative infinity float64", + input: math.Inf(-1), + expected: "-Inf", + }, + { + name: "normal float32", + input: float32(42.5), + expected: float32(42.5), + }, + { + name: "NaN float32", + input: float32(math.NaN()), + expected: "NaN", + }, + { + name: "slice with NaN", + input: []interface{}{1.0, math.NaN(), 3.0}, + expected: []interface{}{1.0, "NaN", 3.0}, + }, + { + name: "map with NaN", + input: map[string]interface{}{ + "normal": 1.0, + "nan": math.NaN(), + "inf": math.Inf(1), + }, + expected: map[string]interface{}{ + "normal": 1.0, + "nan": "NaN", + "inf": "Inf", + }, + }, + { + name: "nested structure", + input: map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "score": math.NaN(), + "count": 10, + }, + }, + }, + expected: map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "score": "NaN", + "count": 10, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeValue(tt.input) + + // For complex types, marshal to JSON and compare + gotJSON, _ := json.Marshal(got) + expectedJSON, _ := json.Marshal(tt.expected) + + if string(gotJSON) != string(expectedJSON) { + t.Errorf("sanitizeValue() = %v, want %v", string(gotJSON), string(expectedJSON)) + } + }) + } +}