Compare commits

...

1 Commits

Author SHA1 Message Date
Tushar Vats
87543f9123 fix: handling missing keys metadata 2026-07-13 06:08:57 +05:30
19 changed files with 589 additions and 64 deletions

View File

@@ -30,6 +30,12 @@ func newFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
// SynthesizeKeys returns nil: rule-state history has no attribute-map fallback, so a
// context-missing key stays unresolved and the caller errors.
func (m *fieldMapper) SynthesizeKeys(_ *telemetrytypes.TelemetryFieldKey, _ any) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) { //nolint:unparam
name := strings.TrimSpace(key.Name)
if col, ok := ruleStateHistoryColumns[name]; ok {

View File

@@ -76,23 +76,24 @@ func CollisionHandledFinalExpr(
}
if len(keysForField) == 0 {
// - the context is not provided
// - there are not keys for the field
// - it is not a static field
// - the next best thing to do is see if there is a typo
// and suggest a correction
// No metadata match: let the field mapper synthesize type-variant keys and query
// anyway (existence is a property of the data, not of metadata). Applies to the
// attribute-map signals (#5380).
keysForField = fm.SynthesizeKeys(field, nil)
}
if len(keysForField) == 0 {
// The mapper can't synthesize (e.g. metrics): fall back to the typo suggestion.
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
return "", nil, wrappedErr
} else {
for _, key := range keysForField {
err := addCondition(key)
if err != nil {
return "", nil, err
}
fieldExpression, _ = fm.FieldFor(ctx, startNs, endNs, key)
fieldExpression, _ = DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
stmts = append(stmts, fieldExpression)
}
for _, key := range keysForField {
err := addCondition(key)
if err != nil {
return "", nil, err
}
fieldExpression, _ = fm.FieldFor(ctx, startNs, endNs, key)
fieldExpression, _ = DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
stmts = append(stmts, fieldExpression)
}
} else {
err := addCondition(field)

View File

@@ -1,6 +1,7 @@
package querybuilder
import (
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
@@ -68,6 +69,104 @@ func NewKeyNotFoundError(name string) error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "key `%s` not found", name).WithUrl(KeyNotFoundDocURL)
}
// NewKeyNotFoundWarning is the warning surfaced when a referenced key is absent from
// metadata and the query falls back to synthesized keys. The query still runs; the
// warning keeps typo protection visible.
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 for a
// referenced key. Existence is a property of the data, not of metadata, so instead of
// failing we query the column(s) the user's input implies:
//
// - A qualified key (context and/or data type already set, e.g. `resource.x` or
// `x:string`) is honored as-is.
// - A bare key defaults to attribute context; its data type is inferred from the
// operand (`x = 'a'` -> string, `x > 5` -> number, `x = true` -> bool), or fanned
// out across string/number/bool when there is no operand (exists / group by).
func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
base := *field
if base.FieldContext == telemetrytypes.FieldContextUnspecified {
base.FieldContext = telemetrytypes.FieldContextAttribute
}
// A set data type, or a single-typed context (resource columns are all strings),
// needs only one synthesized key.
if base.FieldDataType != telemetrytypes.FieldDataTypeUnspecified ||
base.FieldContext == telemetrytypes.FieldContextResource {
clone := base
return []*telemetrytypes.TelemetryFieldKey{&clone}
}
dataTypes := inferDataTypesFromOperand(value)
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(dataTypes))
for _, dt := range dataTypes {
clone := base
clone.FieldDataType = dt
keys = append(keys, &clone)
}
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 operator
// on a builder that doesn't support it (logs body only), or nil for other operators.
func NewFunctionUnsupportedError(operator qbtypes.FilterOperator) error {

View File

@@ -0,0 +1,89 @@
package querybuilder
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSynthesizeKeys(t *testing.T) {
tests := []struct {
name string
field telemetrytypes.TelemetryFieldKey
value any
wantContexts []telemetrytypes.FieldContext
wantDataTypes []telemetrytypes.FieldDataType
}{
{
name: "bare key with string operand infers string",
field: telemetrytypes.TelemetryFieldKey{Name: "error.type"},
value: "timeout",
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString},
},
{
name: "bare key with number operand infers number",
field: telemetrytypes.TelemetryFieldKey{Name: "http.status"},
value: float64(500),
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeFloat64},
},
{
name: "bare key with bool operand infers bool",
field: telemetrytypes.TelemetryFieldKey{Name: "sampled"},
value: true,
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeBool},
},
{
name: "bare key with no operand fans out across variants",
field: telemetrytypes.TelemetryFieldKey{Name: "exception.type"},
value: nil,
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeBool},
},
{
name: "qualified data type honored as-is",
field: telemetrytypes.TelemetryFieldKey{Name: "custom.key", FieldDataType: telemetrytypes.FieldDataTypeString},
value: nil,
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString},
},
{
name: "qualified resource context honored as single key",
field: telemetrytypes.TelemetryFieldKey{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource},
value: nil,
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextResource},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeUnspecified},
},
{
name: "in list with homogeneous strings infers single string variant",
field: telemetrytypes.TelemetryFieldKey{Name: "error.type"},
value: []any{"a", "b"},
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString},
},
{
name: "in list with mixed types fans out to present variants",
field: telemetrytypes.TelemetryFieldKey{Name: "error.type"},
value: []any{"a", float64(1)},
wantContexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextAttribute},
wantDataTypes: []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString, telemetrytypes.FieldDataTypeFloat64},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
field := tt.field
keys := SynthesizeKeys(&field, tt.value)
require.Len(t, keys, len(tt.wantDataTypes))
for i, k := range keys {
assert.Equal(t, tt.field.Name, k.Name, "name preserved")
assert.Equal(t, tt.wantContexts[i], k.FieldContext)
assert.Equal(t, tt.wantDataTypes[i], k.FieldDataType)
}
})
}
}

View File

@@ -192,7 +192,14 @@ func (c *conditionBuilder) ConditionFor(
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
// Existence is a property of the data, not of metadata: let the field mapper
// synthesize key(s) and query anyway, with a loud warning so typos still surface.
// A mapper that can't synthesize (nil) keeps the original not-found error.
keys = c.fm.SynthesizeKeys(key, value)
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
}
conds := make([]string, 0, len(keys))

View File

@@ -3,14 +3,14 @@ package telemetryaudit
import (
"context"
"fmt"
"strings"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
type fieldMapper struct{}
@@ -108,14 +108,31 @@ func (m *fieldMapper) ColumnExpressionFor(
if _, ok := auditLogColumns[field.Name]; ok {
field.FieldContext = telemetrytypes.FieldContextLog
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
} else {
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
return "", wrappedErr
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
} else {
// No metadata match and not a static field: existence is a property of the
// data, not of metadata, so synthesize type-variant keys and query anyway.
keysForField = m.SynthesizeKeys(field, nil)
}
if len(keysForField) == 1 {
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
} else {
// select any non-empty value from the keys
args := []string{}
for _, key := range keysForField {
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", fieldExpression, fieldExpression))
}
fieldExpression = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))
}
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
// SynthesizeKeys fans a context-missing key out across the audit-log attribute maps
// (string/number/bool). See querybuilder.SynthesizeKeys for the type-inference rules.
func (m *fieldMapper) SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
return querybuilder.SynthesizeKeys(field, value)
}

View File

@@ -11,6 +11,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -225,3 +226,39 @@ func TestStatementBuilder(t *testing.T) {
})
}
}
// TestStatementBuilderAuditUnseenKey covers the KeyNotFound fallback (#5380) for audit:
// filtering or grouping by an attribute absent from metadata synthesizes type-variant
// columns and builds instead of erroring.
func TestStatementBuilderAuditUnseenKey(t *testing.T) {
statementBuilder := newTestAuditStatementBuilder(t)
ctx := context.Background()
t.Run("filter on unseen key synthesizes attribute column", func(t *testing.T) {
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "custom.attr = 'v'"},
Limit: 10,
}
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err) // guard: q is nil on error
assert.Contains(t, q.Query, "attributes_string['custom.attr']")
})
t.Run("group by unseen key fans out across type variants", func(t *testing.T) {
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "custom.attr"}}},
Limit: 10,
}
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err) // guard: q is nil on error
assert.Contains(t, q.Query, "attributes_string['custom.attr']")
assert.Contains(t, q.Query, "attributes_number['custom.attr']")
assert.Contains(t, q.Query, "attributes_bool['custom.attr']")
})
}

View File

@@ -398,7 +398,14 @@ func (c *conditionBuilder) ConditionFor(
!c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})):
keys = []*telemetrytypes.TelemetryFieldKey{key}
default:
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
// Existence is a property of the data, not of metadata: let the field mapper
// synthesize key(s) and query anyway, with a loud warning so typos still surface.
// A mapper that can't synthesize (nil) keeps the original not-found error.
keys = c.fm.SynthesizeKeys(key, value)
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
}
}

View File

@@ -9,13 +9,12 @@ import (
"github.com/SigNoz/signoz-otel-collector/utils"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
var (
@@ -257,16 +256,14 @@ func (m *fieldMapper) ColumnExpressionFor(
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextLog
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
} else {
// - the context is not provided
// - there are not keys for the field
// - it is not a static field
// - the next best thing to do is see if there is a typo
// and suggest a correction
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
return "", wrappedErr
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
} else if len(keysForField) == 1 {
// No metadata match and not a static field: existence is a property of the
// data, not of metadata, so synthesize type-variant keys and query anyway.
keysForField = m.SynthesizeKeys(field, nil)
}
if len(keysForField) == 1 {
// we have a single key for the field, use it
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
} else {
@@ -285,6 +282,12 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
// SynthesizeKeys fans a context-missing key out across the log attribute maps
// (string/number/bool). See querybuilder.SynthesizeKeys for the type-inference rules.
func (m *fieldMapper) SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
return querybuilder.SynthesizeKeys(field, value)
}
// buildFieldForJSON builds the field expression for body JSON fields using arrayConcat pattern.
func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (string, error) {
plan := key.JSONPlan

View File

@@ -736,10 +736,10 @@ func TestFilterExprLogs(t *testing.T) {
{
category: "Key-operator-value boundary",
query: "greater>than",
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "key `greater` not found",
shouldPass: true,
expectedQuery: "WHERE (attributes_string['greater'] > ? AND mapContains(attributes_string, 'greater') = ?)",
expectedArgs: []any{"than", true},
expectedErrorContains: "",
},
{
category: "Key-operator-value boundary",
@@ -752,10 +752,10 @@ func TestFilterExprLogs(t *testing.T) {
{
category: "Key-operator-value boundary",
query: "less<than",
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "key `less` not found",
shouldPass: true,
expectedQuery: "WHERE (attributes_string['less'] < ? AND mapContains(attributes_string, 'less') = ?)",
expectedArgs: []any{"than", true},
expectedErrorContains: "",
},
{
category: "Key-operator-value boundary",
@@ -808,10 +808,10 @@ func TestFilterExprLogs(t *testing.T) {
{
category: "Key-operator-value boundary",
query: "user=admin",
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "key `user` not found",
shouldPass: true,
expectedQuery: "WHERE (attributes_string['user'] = ? AND mapContains(attributes_string, 'user') = ?)",
expectedArgs: []any{"admin", true},
expectedErrorContains: "",
},
{
category: "Key-operator-value boundary",
@@ -2019,10 +2019,10 @@ func TestFilterExprLogs(t *testing.T) {
{
category: "Nested object paths",
query: "response.body.data.items[].id=123",
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "key `response.body.data.items[].id` not found",
shouldPass: true,
expectedQuery: "WHERE (toFloat64(attributes_number['response.body.data.items[].id']) = ? AND mapContains(attributes_number, 'response.body.data.items[].id') = ?)",
expectedArgs: []any{float64(123), true},
expectedErrorContains: "",
},
{
category: "Nested object paths",
@@ -2259,7 +2259,9 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "key `user_id` not found",
// user_id / subscription_type now synthesize; the query still errors, but on the
// pre-existing has()-needs-body-JSON constraint (unrelated to #5380).
expectedErrorContains: "function `has` supports only body JSON search",
},
// More common filter patterns

View File

@@ -16,6 +16,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -1305,3 +1306,47 @@ func newSkipResourceFingerprintLogsBuilder(
threshold,
)
}
// TestStatementBuilderLogsGroupByUnseenKey covers the group-by half of the KeyNotFound
// fallback (#5380) for logs: grouping by an attribute absent from metadata fans out across
// type variants and builds instead of erroring.
func TestStatementBuilderLogsGroupByUnseenKey(t *testing.T) {
ctx := context.Background()
fl := flaggertest.New(t)
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
fm := NewFieldMapper(fl)
cb := NewConditionBuilder(fm, fl)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "custom.attr"}},
},
}
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err) // guard: q is nil on error
assert.Contains(t, q.Query, "attributes_string['custom.attr']")
assert.Contains(t, q.Query, "attributes_number['custom.attr']")
assert.Contains(t, q.Query, "attributes_bool['custom.attr']")
}

View File

@@ -29,6 +29,12 @@ var (
type fieldMapper struct {
}
// SynthesizeKeys returns nil: this mapper has no attribute-map fallback, so a context-missing
// key stays unresolved and the caller errors.
func (m *fieldMapper) SynthesizeKeys(_ *telemetrytypes.TelemetryFieldKey, _ any) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}

View File

@@ -37,6 +37,12 @@ var (
type fieldMapper struct{}
// SynthesizeKeys returns nil: metrics has no attribute-map fallback, so a context-missing
// key stays unresolved and the caller errors.
func (m *fieldMapper) SynthesizeKeys(_ *telemetrytypes.TelemetryFieldKey, _ any) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}

View File

@@ -21,6 +21,12 @@ type defaultFieldMapper struct{}
var _ qbtypes.FieldMapper = (*defaultFieldMapper)(nil)
// SynthesizeKeys returns nil: the resource filter has no attribute-map fallback, so a
// context-missing key stays unresolved and the caller errors.
func (m *defaultFieldMapper) SynthesizeKeys(_ *telemetrytypes.TelemetryFieldKey, _ any) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func NewFieldMapper() *defaultFieldMapper {
return &defaultFieldMapper{}
}

View File

@@ -276,7 +276,14 @@ func (c *conditionBuilder) ConditionFor(
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
// Existence is a property of the data, not of metadata: let the field mapper
// synthesize key(s) and query anyway, with a loud warning so typos still surface.
// A mapper that can't synthesize (nil) keeps the original not-found error.
keys = c.fm.SynthesizeKeys(key, value)
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
}
conds := make([]string, 0, len(keys))

View File

@@ -388,3 +388,95 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
})
}
}
// TestConditionForSynthesizedKeys covers the KeyNotFound fallback (#5380): when a
// referenced attribute key has no metadata match, the builder synthesizes key(s) from
// user input and queries anyway, emitting a warning instead of failing.
func TestConditionForSynthesizedKeys(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
// no metadata matches -> the builder must synthesize from user input
var noMatches []*telemetrytypes.TelemetryFieldKey
t.Run("bare key with string operand -> attribute string", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "error.type"}
conds, warnings, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorEqual, "timeout", sb)
assert.NoError(t, err)
assert.NotEmpty(t, warnings, "a not-found warning should be emitted")
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_string['error.type']")
assert.Contains(t, args, "timeout")
})
t.Run("bare key with number operand -> attribute number", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "http.status"}
conds, warnings, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorGreaterThan, float64(5), sb)
assert.NoError(t, err)
assert.NotEmpty(t, warnings)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_number['http.status']")
})
t.Run("bare key with bool operand -> attribute bool", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "sampled"}
conds, _, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorEqual, true, sb)
assert.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_bool['sampled']")
})
t.Run("exists with no operand fans out across type variants", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "exception.type"}
conds, warnings, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorExists, nil, sb)
assert.NoError(t, err)
assert.NotEmpty(t, warnings)
assert.Len(t, conds, 3, "exists should fan out to string/number/bool")
sb.Where(sb.Or(conds...))
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "mapContains(attributes_string, 'exception.type')")
assert.Contains(t, sql, "mapContains(attributes_number, 'exception.type')")
assert.Contains(t, sql, "mapContains(attributes_bool, 'exception.type')")
})
t.Run("qualified data type honored without fanout", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "custom.key", FieldDataType: telemetrytypes.FieldDataTypeString}
conds, _, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorEqual, "v", sb)
assert.NoError(t, err)
assert.Len(t, conds, 1)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_string['custom.key']")
})
t.Run("qualified resource context honored", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource}
conds, _, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorEqual, "prod", sb)
assert.NoError(t, err)
assert.Len(t, conds, 1)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "resources_string")
})
t.Run("negative operator builds without exists guard (matches-everything semantics)", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "error.type"}
conds, _, err := cb.ConditionFor(ctx, 0, 0, &key, noMatches, qbtypes.FilterOperatorNotEqual, "fatal", sb)
assert.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "attributes_string['error.type'] <> ?")
assert.NotContains(t, sql, "mapContains")
})
}

View File

@@ -7,10 +7,10 @@ import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
var (
@@ -362,16 +362,14 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextSpan
fieldExpression, _ = m.FieldFor(ctx, startNs, endNs, field)
} else {
// - the context is not provided
// - there are not keys for the field
// - it is not a static field
// - the next best thing to do is see if there is a typo
// and suggest a correction
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
return "", wrappedErr
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
} else if len(keysForField) == 1 {
// No metadata match and not a static field: existence is a property of the
// data, not of metadata, so synthesize type-variant keys and query anyway.
keysForField = m.SynthesizeKeys(field, nil)
}
if len(keysForField) == 1 {
// we have a single key for the field, use it
fieldExpression, _ = m.FieldFor(ctx, startNs, endNs, keysForField[0])
} else {
@@ -387,3 +385,9 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}
// SynthesizeKeys fans a context-missing key out across the span attribute maps
// (string/number/bool). See querybuilder.SynthesizeKeys for the type-inference rules.
func (m *defaultFieldMapper) SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
return querybuilder.SynthesizeKeys(field, value)
}

View File

@@ -16,6 +16,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -630,7 +631,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
expectedErr: nil,
},
{
name: "List query with legacy fields with field that doesn't exist",
name: "List query with a select field not in metadata synthesizes type-variant columns",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
@@ -665,8 +666,11 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
Limit: 10,
},
expected: qbtypes.Statement{},
expectedErr: errors.NewInvalidInputf(errors.CodeInvalidInput, "field not found"),
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(attributes_string['non-existent.key']) != '', toString(attributes_string['non-existent.key']), toString(attributes_number['non-existent.key']) != '', toString(attributes_number['non-existent.key']), toString(attributes_bool['non-existent.key']) != '', toString(attributes_bool['non-existent.key']), NULL) AS `non-existent.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
@@ -1700,3 +1704,84 @@ func newSkipResourceFingerprintBuilder(
threshold,
)
}
// TestStatementBuilderGroupByUnseenKey covers the group-by/aggregation half of the
// KeyNotFound fallback (#5380): grouping by an attribute absent from metadata must
// fan out across type variants and build, not error.
func TestStatementBuilderGroupByUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "error.type"}},
},
}
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err) // guard: q is nil on error
assert.Contains(t, q.Query, "attributes_string['error.type']")
assert.Contains(t, q.Query, "attributes_number['error.type']")
assert.Contains(t, q.Query, "attributes_bool['error.type']")
}
// TestStatementBuilderAggregationUnseenKey covers the aggregation half of the KeyNotFound
// fallback (#5380): aggregating over an attribute absent from metadata (via the shared
// agg-expr rewriter) must fan out across type variants and build, not error.
func TestStatementBuilderAggregationUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count_distinct(error.type)"}},
Limit: 10,
}
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err) // guard: q is nil on error
assert.Contains(t, q.Query, "attributes_string['error.type']")
assert.Contains(t, q.Query, "attributes_number['error.type']")
assert.Contains(t, q.Query, "attributes_bool['error.type']")
}

View File

@@ -27,6 +27,12 @@ type FieldMapper interface {
ColumnFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error)
// ColumnExpressionFor returns the column expression for the given key.
ColumnExpressionFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, error)
// SynthesizeKeys decides, per data source, which key(s) to query when a referenced key
// has no metadata match (context missing). It assumes upstream has already resolved
// metadata, so it only reads the key's own context/type plus the operand value. Returns
// nil to signal "cannot synthesize" (caller then errors); attribute-map sources fan out
// across type variants. value is the filter operand, or nil for select/group-by.
SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey
}
// ConditionBuilder builds the conditions for the filter.