Files
signoz/pkg/querybuilder/key_resolution.go
Nikhil Soni 9f03fea0f3
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
fix(querybuilder): prefer resource over any context for ambiguous filter keys (#12888)
#### Description

- A logs filter on a bare key that lives in **both** resource and
another context (body or scope) ANDed the two: the resource candidate
built the `__resource_filter` fingerprint CTE while the other candidate
landed as a required main-query term, so the query matched almost
nothing.
- `ResolveLogicalFields` only preferred resource over `attribute`.
Generalized it to prefer resource over **any** other context (attribute,
body, scope, …); other contexts stay reachable via their qualified names
(e.g. `body.service.name`).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#6086
Part of https://github.com/SigNoz/platform-pod/issues/3158

#### Additional Information

Generalized rather than special-casing body/scope, since any future
context would hit the same fingerprint-CTE trap.
2026-09-17 14:45:45 +00:00

221 lines
9.4 KiB
Go

package querybuilder
import (
"encoding/json"
"fmt"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// FieldContextDataTypesDocURL documents how to disambiguate a key that resolves
// to multiple field context / data type combinations.
FieldContextDataTypesDocURL = "https://signoz.io/docs/userguide/field-context-data-types/"
// KeyNotFoundDocURL documents the "key not found" error.
KeyNotFoundDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-key-fieldname-not-found--why-cant-it-find-my-field"
// Doc URLs for the has/hasAny/hasAll and hasToken "unsupported" errors.
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#q-im-getting-function-supports-only-body-json-search--can-i-use-functions-on-other-fields"
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
)
// ResolveLogicalFields picks which logical fields a filter term builds conditions
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
// name is ambiguous (several logical fields — a family is one field and never
// ambiguous with itself) it returns a warning; a resource + other-context mix
// (attribute, body, scope, …) defaults to the resource fields (the common
// intent), noted in the warning.
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
if len(logicalFields) <= 1 {
return logicalFields, ""
}
warning := fmt.Sprintf(
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
field.Name,
len(logicalFields),
logicalFields,
)
hasResource, hasOther := false, false
for _, item := range logicalFields {
if item.FieldContext == telemetrytypes.FieldContextResource {
hasResource = true
} else {
hasOther = true
}
}
// with resource and any other context, default to resource only
if hasResource && hasOther {
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
for _, item := range logicalFields {
if item.FieldContext == telemetrytypes.FieldContextResource {
filtered = append(filtered, item)
}
}
logicalFields = filtered
warning += " " + "Using `resource` context by default. To query another context explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s' or 'body.%s')", field.Name, field.Name)
}
return logicalFields, warning
}
// ColumnDataType is the field data type a table column reads as. A storage
// stamps it on the column key its Fallback returns. The intrinsic-column
// step can then drop a same-named metadata key of a contradicting type. A
// time column has no field data type and matches none.
func ColumnDataType(column *schema.Column) telemetrytypes.FieldDataType {
switch column.Type.GetType() {
case schema.ColumnTypeEnumBool:
return telemetrytypes.FieldDataTypeBool
case schema.ColumnTypeEnumInt8, schema.ColumnTypeEnumInt16, schema.ColumnTypeEnumInt32, schema.ColumnTypeEnumInt64,
schema.ColumnTypeEnumUInt8, schema.ColumnTypeEnumUInt16, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumFloat32, schema.ColumnTypeEnumFloat64:
return telemetrytypes.FieldDataTypeNumber
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFixedString:
return telemetrytypes.FieldDataTypeString
case schema.ColumnTypeEnumLowCardinality:
if lc, ok := column.Type.(schema.LowCardinalityColumnType); ok && lc.ElementType.GetType() == schema.ColumnTypeEnumString {
return telemetrytypes.FieldDataTypeString
}
}
return telemetrytypes.FieldDataTypeUnspecified
}
// WrapAsLogicalFields wraps physical keys (candidate or synthesized) as
// single-member logical fields addressed by the requested spelling.
func WrapAsLogicalFields(requestedName string, keys []*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
fields := make([]*telemetrytypes.LogicalField, 0, len(keys))
for _, key := range keys {
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, key))
}
return fields
}
// NewKeyNotFoundError builds the error for a key that neither metadata nor
// the storage can serve, with the closest known names as suggestions.
func NewKeyNotFoundError(name string, known []string) error {
err := errors.NewInvalidInputf(errors.CodeInvalidInput, "key `%s` not found", name).WithUrl(KeyNotFoundDocURL)
if len(known) == 0 {
return err
}
return err.WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(name, errors.NounKeys, known)...)
}
// NewKeyNotFoundWarning is the warning surfaced when a referenced key is absent from
// metadata and the query falls back to synthesized keys.
func NewKeyNotFoundWarning(name string) string {
return fmt.Sprintf("key `%s` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos.", name)
}
// SynthesizeKeys builds the field keys to query when metadata has no match: a qualified
// key is honored as-is; a bare key defaults to attribute context with the data type
// inferred from the operand, or fanned out across string/number/bool without one.
func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
fieldContext := field.FieldContext
if fieldContext == telemetrytypes.FieldContextUnspecified {
fieldContext = telemetrytypes.FieldContextAttribute
}
fieldDataType := field.FieldDataType
// Resource and scope values are strings; pin the type so operand coercion applies.
if (fieldContext == telemetrytypes.FieldContextResource || fieldContext == telemetrytypes.FieldContextScope) &&
fieldDataType == telemetrytypes.FieldDataTypeUnspecified {
fieldDataType = telemetrytypes.FieldDataTypeString
}
// A set data type needs only one synthesized key. It keeps the request
// key's physical data (evolutions, materialization, JSON plan): a key the
// caller decorated reads through those even when metadata is silent.
if fieldDataType != telemetrytypes.FieldDataTypeUnspecified {
key := *field
key.FieldContext = fieldContext
key.FieldDataType = fieldDataType
return []*telemetrytypes.TelemetryFieldKey{&key}
}
dataTypes := inferDataTypesFromOperand(value)
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(dataTypes))
for _, dt := range dataTypes {
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(field.Name, fieldContext, dt))
}
return keys
}
// allVariantDataTypes is the fanout used when no operand pins the type (exists / group by).
func allVariantDataTypes() []telemetrytypes.FieldDataType {
return []telemetrytypes.FieldDataType{
telemetrytypes.FieldDataTypeString,
telemetrytypes.FieldDataTypeNumber,
telemetrytypes.FieldDataTypeBool,
}
}
// inferDataTypesFromOperand maps an operand value to the data type(s) to query. With no
// operand or an unrecognized type it fans out across all variants.
func inferDataTypesFromOperand(value any) []telemetrytypes.FieldDataType {
switch v := value.(type) {
case nil:
return allVariantDataTypes()
case string:
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString}
case bool:
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeBool}
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeNumber}
case []any:
return inferDataTypesFromList(v)
default:
return allVariantDataTypes()
}
}
// inferDataTypesFromList derives the distinct data types present in an `in` list, kept
// in string/number/bool order; an empty or all-unknown list fans out.
func inferDataTypesFromList(values []any) []telemetrytypes.FieldDataType {
var hasString, hasNumber, hasBool bool
for _, v := range values {
switch v.(type) {
case string:
hasString = true
case bool:
hasBool = true
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
hasNumber = true
}
}
var out []telemetrytypes.FieldDataType
if hasString {
out = append(out, telemetrytypes.FieldDataTypeString)
}
if hasNumber {
out = append(out, telemetrytypes.FieldDataTypeNumber)
}
if hasBool {
out = append(out, telemetrytypes.FieldDataTypeBool)
}
if len(out) == 0 {
return allVariantDataTypes()
}
return out
}
// NewFunctionUnsupportedError returns the error for a has/hasAny/hasAll/hasToken/search
// operator on a builder that doesn't support it (logs only), or nil for other operators.
func NewFunctionUnsupportedError(operator qbtypes.FilterOperator) error {
switch operator {
case qbtypes.FilterOperatorHasToken:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` supports only body JSON search", operator.FunctionName()).WithUrl(functionBodyJSONSearchDocURL)
case qbtypes.FilterOperatorSearch:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `search` is only supported for logs")
default:
return nil
}
}