mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-02 19:30:33 +01:00
A span/log query that aggregates or filters a numeric intrinsic column (e.g. duration_nano) failed with ClickHouse NO_COMMON_TYPE (386) when a same-named, type-consistent attribute existed in the metadata: field-key resolution unioned the intrinsic column with the attribute into a multiIf/OR whose branches had incompatible types (UInt64 intrinsic vs Float64 attribute). This broke /api/v1/span_percentile on affected tenants. - fallback_expr: DataTypeCollisionHandledFieldName now handles the unspecified data type in the projection/aggregation path (operator Unknown), coercing the column so collision multiIf branches share a supertype. Comparison contexts are left bare so the column index stays usable. - telemetrytraces/condition_builder: run collision handling for duration_nano (so a same-named string attribute is cast in comparisons) and coerce numeric duration values to int64, keeping the intrinsic comparison bare/index-friendly while preserving the duration-string QoL parsing. - tests: add a type-consistent "collision" trace_noise variant; cover it in the percentile aggregation test and add a duration_nano QoL filter regression test.
230 lines
7.3 KiB
Go
230 lines
7.3 KiB
Go
package querybuilder
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
|
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
|
)
|
|
|
|
func GroupByKeys(keys []qbtypes.GroupByKey) []string {
|
|
k := []string{}
|
|
for _, key := range keys {
|
|
k = append(k, "`"+key.Name+"`")
|
|
}
|
|
return k
|
|
}
|
|
|
|
func FormatValueForContains(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
return v
|
|
case []byte:
|
|
return string(v)
|
|
|
|
case json.Number:
|
|
return v.String()
|
|
|
|
case float64:
|
|
if v == math.Trunc(v) && v >= -1e15 && v <= 1e15 {
|
|
return fmt.Sprintf("%.0f", v)
|
|
}
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
|
|
case float32:
|
|
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
|
|
|
case int, int8, int16, int32, int64:
|
|
return fmt.Sprintf("%d", v)
|
|
|
|
case uint, uint8, uint16, uint32, uint64:
|
|
return fmt.Sprintf("%d", v)
|
|
|
|
case bool:
|
|
return strconv.FormatBool(v)
|
|
|
|
case fmt.Stringer:
|
|
return v.String()
|
|
|
|
default:
|
|
// fallback - try to convert through reflection
|
|
rv := reflect.ValueOf(value)
|
|
switch rv.Kind() {
|
|
case reflect.Float32, reflect.Float64:
|
|
f := rv.Float()
|
|
if f == math.Trunc(f) && f >= -1e15 && f <= 1e15 {
|
|
return fmt.Sprintf("%.0f", f)
|
|
}
|
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return strconv.FormatInt(rv.Int(), 10)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return strconv.FormatUint(rv.Uint(), 10)
|
|
default:
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func FormatFullTextSearch(input string) string {
|
|
if _, err := regexp.Compile(input); err != nil {
|
|
// Not a valid regex -> treat as literal substring
|
|
return regexp.QuoteMeta(input)
|
|
}
|
|
return input
|
|
}
|
|
|
|
func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, value any, tblFieldName string, operator qbtypes.FilterOperator) (string, any) {
|
|
// This block of code exists to handle the data type collisions
|
|
// We don't want to fail the requests when there is a key with more than one data type
|
|
// Let's take an example of `http.status_code`, and consider user sent a string value and number value
|
|
// When they search for `http.status_code=200`, we will search across both the number columns and string columns
|
|
// and return the results from both the columns
|
|
// While we expect user not to send the mixed data types, it inevitably happens
|
|
// So we handle the data type collisions here
|
|
switch key.FieldDataType {
|
|
case telemetrytypes.FieldDataTypeString, telemetrytypes.FieldDataTypeArrayString:
|
|
switch v := value.(type) {
|
|
case float64:
|
|
// try to convert the string value to to number
|
|
tblFieldName = castFloat(tblFieldName)
|
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
|
tblFieldName = castFloat(tblFieldName)
|
|
case []any:
|
|
if allFloats(v) {
|
|
tblFieldName = castFloat(tblFieldName)
|
|
} else {
|
|
// Any mix that is not all-floats (e.g. [bool, float64], [bool], all-strings)
|
|
// must be stringified: passing a Go bool as UInt8 against a String column
|
|
// causes ClickHouse error 386 "no supertype for String and UInt8".
|
|
_, value = castString(tblFieldName), toStrings(v)
|
|
}
|
|
case bool:
|
|
// we don't have a toBoolOrNull in ClickHouse, so we need to convert the bool to a string
|
|
value = fmt.Sprintf("%t", v)
|
|
}
|
|
case telemetrytypes.FieldDataTypeInt64,
|
|
telemetrytypes.FieldDataTypeArrayInt64,
|
|
telemetrytypes.FieldDataTypeNumber,
|
|
telemetrytypes.FieldDataTypeArrayNumber,
|
|
telemetrytypes.FieldDataTypeFloat64,
|
|
telemetrytypes.FieldDataTypeArrayFloat64:
|
|
switch v := value.(type) {
|
|
// why? ; CH returns an error for a simple check
|
|
// attributes_number['http.status_code'] = 200 but not for attributes_number['http.status_code'] >= 200
|
|
// DB::Exception: Bad get: has UInt64, requested Float64.
|
|
// How is it working in v4? v4 prepares the full query with values in query string
|
|
// When we format the float it becomes attributes_number['http.status_code'] = 200.000
|
|
// Which CH gladly accepts and doesn't throw error
|
|
// However, when passed as query args, the default formatter
|
|
// https://github.com/ClickHouse/clickhouse-go/blob/757e102f6d8c6059d564ce98795b4ce2a101b1a5/bind.go#L393
|
|
// is used which prepares the
|
|
// final query as attributes_number['http.status_code'] = 200 giving this error
|
|
// This following is one way to workaround it
|
|
|
|
// if the key is a number, the value is a string, we will let clickHouse handle the conversion
|
|
case float32, float64:
|
|
tblFieldName = castFloatHack(tblFieldName)
|
|
case string:
|
|
// check if it's a number inside a string
|
|
isNumber := false
|
|
if _, err := strconv.ParseFloat(v, 64); err == nil {
|
|
isNumber = true
|
|
}
|
|
|
|
if !operator.IsComparisonOperator() || !isNumber {
|
|
// try to convert the number attribute to string
|
|
tblFieldName = castString(tblFieldName) // numeric col vs string literal
|
|
} else {
|
|
tblFieldName = castFloatHack(tblFieldName)
|
|
}
|
|
case []any:
|
|
if allFloats(v) {
|
|
tblFieldName = castFloatHack(tblFieldName)
|
|
} else if hasString(v) {
|
|
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
|
}
|
|
case bool:
|
|
// a bool can't equal a number; compare as strings (type-safe, matches
|
|
// nothing) instead of erroring with "Bad get: ... Float64" (CH 170).
|
|
tblFieldName, value = castString(tblFieldName), fmt.Sprintf("%t", v)
|
|
}
|
|
|
|
case telemetrytypes.FieldDataTypeBool,
|
|
telemetrytypes.FieldDataTypeArrayBool:
|
|
switch v := value.(type) {
|
|
case string:
|
|
tblFieldName = castString(tblFieldName)
|
|
case []any:
|
|
if hasString(v) {
|
|
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
|
}
|
|
}
|
|
case telemetrytypes.FieldDataTypeArrayDynamic:
|
|
switch v := value.(type) {
|
|
case string:
|
|
tblFieldName = castString(tblFieldName)
|
|
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
|
tblFieldName = accurateCastFloat(tblFieldName)
|
|
case bool:
|
|
tblFieldName = castBool(tblFieldName)
|
|
case []any:
|
|
// dynamic array elements will be default casted to string
|
|
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
|
}
|
|
case telemetrytypes.FieldDataTypeUnspecified:
|
|
if operator == qbtypes.FilterOperatorUnknown {
|
|
switch value.(type) {
|
|
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
|
tblFieldName = accurateCastFloat(tblFieldName)
|
|
case string:
|
|
tblFieldName = castString(tblFieldName)
|
|
}
|
|
}
|
|
}
|
|
return tblFieldName, value
|
|
}
|
|
|
|
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
|
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
|
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
|
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
|
func accurateCastFloat(col string) string {
|
|
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
|
|
}
|
|
|
|
func allFloats(in []any) bool {
|
|
for _, x := range in {
|
|
if _, ok := x.(float64); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hasString(in []any) bool {
|
|
for _, x := range in {
|
|
if _, ok := x.(string); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toStrings(in []any) []any {
|
|
out := make([]any, len(in))
|
|
for i, x := range in {
|
|
out[i] = fmt.Sprintf("%v", x)
|
|
}
|
|
return out
|
|
}
|