mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-13 10:00:32 +01:00
Compare commits
1 Commits
main
...
worktree-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87543f9123 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
89
pkg/querybuilder/key_resolution_test.go
Normal file
89
pkg/querybuilder/key_resolution_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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']")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']")
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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']")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user