Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil Soni
e4d11888d9 fix: make normalization declared fields aware 2026-08-20 17:44:13 +05:30
Nikhil Soni
01009eb727 chore: scope attribute named same as declared scope paths 2026-08-20 16:39:55 +05:30
8 changed files with 93 additions and 67 deletions

View File

@@ -1,6 +1,8 @@
package querybuilder
import (
"strings"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
@@ -59,11 +61,16 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
// https://github.com/SigNoz/signoz/issues/11374
if key.FieldContext == telemetrytypes.FieldContextScope {
//
// A scope attribute lets its `scope.`-prefixed name resolve under other contexts too.
// Declared paths (scope.name/scope.version) keep their compound name after
// normalization and address the scope field only, so they get no such selector.
scopePrefix := telemetrytypes.FieldContextScope.StringValue() + "."
if key.FieldContext == telemetrytypes.FieldContextScope && !strings.HasPrefix(key.Name, scopePrefix) {
keys = append(keys, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Name: scopePrefix + key.Name,
Signal: key.Signal,
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: key.FieldDataType,
})
}

View File

@@ -73,18 +73,15 @@ func TestQueryToKeys(t *testing.T) {
},
},
{
// A declared scope path keeps its compound name and addresses the scope field
// only, so it yields a single scope-context selector (no `scope.`-prefixed
// cross-context companion).
query: `scope.version = '1.0.0'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},

View File

@@ -956,10 +956,11 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
},
},
{
// A scope name that collides with a declared path: with both the declared
// scope.version and a scope attribute literally named `version` in metadata, a
// select on `{version, scope}` unions both (attribute first, declared fallback).
name: "scope select field unions a same-named scope attribute and the declared path",
// A scope name that collides with a declared path: even with a scope attribute
// literally named `version` in metadata alongside the declared scope.version, a
// select on `{version, scope}` binds to the declared path only. The reserved-name
// attribute is addressed separately as scope.attribute.version.
name: "scope select field binds to the declared path, ignoring a same-named scope attribute",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.version": {
@@ -989,7 +990,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL) AS `__SELECT_KEY_3_version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},

View File

@@ -300,10 +300,10 @@ func (m *fieldMapper) resolveColumnExprs(
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextScope:
if isDeclaredScopePath(key.Name) {
if path, ok := declaredScopePath(key); ok {
// declared String paths on the scope column read '' for the missing case
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s <> ''", key.Name))
exprs = append(exprs, fmt.Sprintf("%s::String", path))
existExprs = append(existExprs, fmt.Sprintf("%s <> ''", path))
} else {
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
@@ -428,38 +428,22 @@ func (m *fieldMapper) ColumnExpressionFor(
// Resolve the candidate logical field(s).
var candidates []*telemetrytypes.LogicalField
switch field.FieldContext {
case telemetrytypes.FieldContextScope:
// FieldFor resolves any scope key to a single expression, so the probe below would skip
// the union. Resolve scope the way the filter path does: MatchingLogicalFields surfaces a
// same-named scope attribute (attribute-first) alongside the declared path, and
// CandidateKeys synthesizes when metadata knows neither.
matches := querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
candidates, _ = querybuilder.ResolveLogicalFields(field, matches)
if len(candidates) == 0 {
candidates = querybuilder.WrapAsLogicalFields(field.Name, m.CandidateKeys(ctx, orgID, field, nil, keys))
}
if len(candidates) == 0 {
return "", errors.Wrapf(querybuilder.NewKeyNotFoundError(field.Name), errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// A directly-resolvable key upgrades to its family when the metadata
// map proves membership; otherwise it stays single-member.
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// The legacy candidate flow: column (when the bare name is one) plus metadata
// matches, else synthesized type-variant keys. The family step only swaps candidates
// for their family; it never changes candidate order or non-family behavior.
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(raw) == 0 {
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// A directly-resolvable key upgrades to its family when the metadata
// map proves membership; otherwise it stays single-member.
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// The legacy candidate flow: column (when the bare name is one) plus metadata
// matches, else synthesized type-variant keys. The family step only swaps candidates
// for their family; it never changes candidate order or non-family behavior.
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(raw) == 0 {
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
return "", err
}
return "", err
}
// Group-by/order (String) and aggregation (String/Float64): every candidate is
@@ -648,6 +632,21 @@ func isDeclaredScopePath(name string) bool {
return ok && f.FieldContext == telemetrytypes.FieldContextScope
}
// declaredScopePath returns the compound declared scope path (e.g. `scope.name`) for a scope
// key given in either its short ({name, scope}) or already-compound ({scope.name, scope})
// form, and whether it names a declared path at all. Normalization strips the `scope.` prefix,
// so the declared paths reach the renderers in short form; IntrinsicFields keys them compound.
func declaredScopePath(key *telemetrytypes.TelemetryFieldKey) (string, bool) {
if isDeclaredScopePath(key.Name) {
return key.Name, true
}
compound := telemetrytypes.FieldContextScope.StringValue() + "." + key.Name
if isDeclaredScopePath(compound) {
return compound, true
}
return "", false
}
// scopeJSONExistsExpression renders the existence predicate for the scope JSON column, the one
// signal-specific case the generic querybuilder.ExistsExpression must not carry.
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
@@ -655,7 +654,7 @@ func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpre
return "", false
}
// Declared String paths are non-Nullable (absent reads '' not NULL).
if isDeclaredScopePath(key.Name) {
if _, ok := declaredScopePath(key); ok {
if exists {
return fieldExpression + " <> ''", true
}

View File

@@ -372,10 +372,10 @@ func TestColumnExpressionForScopeUnion(t *testing.T) {
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "short name unions the declared path and a same-named scope attribute",
name: "short name binds to the declared path only, ignoring a same-named scope attribute",
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL)",
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "full scope.version name under scope context addresses the declared path alone",
@@ -384,10 +384,10 @@ func TestColumnExpressionForScopeUnion(t *testing.T) {
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "short scope name unions the declared scope.name and a same-named attribute",
name: "short scope name binds to the declared scope.name only, ignoring a same-named attribute",
key: telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, toString(scope.attributes.`name`::String), scope.name::String <> '', toString(scope.name::String), NULL)",
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
},
{
name: "full scope.name name under scope context addresses the declared path alone",

View File

@@ -145,6 +145,19 @@ func (f *TelemetryFieldKey) Equal(key *TelemetryFieldKey) bool {
// key := &TelemetryFieldKey{Name: "resource.service.name:string"}
// key.Normalize()
// // Result: Name: "service.name", FieldContext: FieldContextResource, FieldDataType: FieldDataTypeString
// declaredScopePathSuffixes are the OTel InstrumentationScope fields that address a
// declared top-level path on the scope column rather than a scope attribute. They keep
// their compound `scope.<suffix>` name through normalization.
var declaredScopePathSuffixes = map[string]struct{}{
"name": {},
"version": {},
}
func isDeclaredScopePathSuffix(name string) bool {
_, ok := declaredScopePathSuffixes[name]
return ok
}
func (f *TelemetryFieldKey) Normalize() {
// Step 1: Parse data type from the right (after the last ":") if not already specified
@@ -163,9 +176,17 @@ func (f *TelemetryFieldKey) Normalize() {
if dotIdx := strings.Index(f.Name, "."); dotIdx != -1 {
potentialContext := f.Name[:dotIdx]
if fc, ok := fieldContexts[potentialContext]; ok && fc != FieldContextUnspecified {
f.Name = f.Name[dotIdx+1:]
remainder := f.Name[dotIdx+1:]
f.FieldContext = fc
// The declared scope paths (scope.name / scope.version) keep their compound
// name so they stay distinct from a scope attribute of the same short name.
if fc == FieldContextScope && isDeclaredScopePathSuffix(remainder) {
// f.Name stays as the compound `scope.<suffix>`
} else {
f.Name = remainder
}
// Step 2a: Handle special case for log.body.* fields
if f.FieldContext == FieldContextLog && strings.HasPrefix(f.Name, BodyJSONStringSearchPrefix) {
f.FieldContext = FieldContextBody

View File

@@ -22,7 +22,7 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
{
keyText: "scope.name",
expected: TelemetryFieldKey{
Name: "name",
Name: "scope.name",
FieldContext: FieldContextScope,
FieldDataType: FieldDataTypeUnspecified,
},
@@ -30,7 +30,7 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
{
keyText: "scope.version",
expected: TelemetryFieldKey{
Name: "version",
Name: "scope.version",
FieldContext: FieldContextScope,
FieldDataType: FieldDataTypeUnspecified,
},

View File

@@ -1308,13 +1308,14 @@ def test_traces_list_with_corrupt_data(
# The explicit `scope.` prefix forces scope context only, so span 0's
# span attribute is ignored — only span 1 matches.
pytest.param("scope.env.tier = 'gold'", [1], id="scope_prefixed_cross_context"),
# `scope.name` matches BOTH the intrinsic scope.name field (span 0) and a
# scope attribute literally named `name` (span 1's scope attribute
# name='io.signoz.checkout').
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_collision"),
# `scope.name` also matches a span attribute literally named `scope.name`
# (attribute context) — span 2 carries attribute scope.name='attr-scope-name'.
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_attribute_collision"),
# `scope.name` binds to the declared scope.name field only (span 0). A scope
# attribute literally named `name` (span 1) is reserved-shadowed and is addressed
# separately as scope.attribute.name, so it does not match here.
pytest.param("scope.name = 'io.signoz.checkout'", [0], id="scope_name_reserved_declared_only"),
# A span attribute literally named `scope.name` is addressed with an explicit
# attribute context; the scope-prefixed spelling binds to the declared field only.
# Span 2 carries attribute scope.name='attr-scope-name'.
pytest.param("attribute.scope.name = 'attr-scope-name'", [2], id="scope_name_attribute_explicit_context"),
# An unprefixed `name` resolves to the intrinsic span `name` column and a
# `name` scope attribute, but NOT the scope.name field. Span 2's span
# name and span 1's scope attribute `name` both equal 'io.signoz.checkout';
@@ -1349,10 +1350,10 @@ def test_traces_list_with_scope_filter(
- Filtering on scope.name / scope.version / a scope attribute.
- An unprefixed key is resolved across contexts (scope checked alongside
attribute / intrinsic), while a `scope.`-prefixed key is scope-only.
- `scope.name` hits the intrinsic field, a `name` scope attribute, and a
span attribute `scope.name` (cross-context), while a bare `name` hits
the span name column (and a `name` scope attribute) but never the
scope.name field.
- `scope.name` hits the declared scope.name field only, not a `name` scope
attribute (reserved-shadowed) nor a span attribute literally named
`scope.name` (addressed as `attribute.scope.name`); a bare `name` hits the
span name column and a `name` scope attribute but never the scope.name field.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()