chore: handle nan/inf in response (#8318)

This commit is contained in:
Srikanth Chekuri 2025-06-20 22:26:25 +05:30 committed by GitHub
parent 7ec59c3c77
commit 5b342b9b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 447 additions and 0 deletions

View File

@ -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))
}

View File

@ -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))
}
})
}
}