Compare commits

...

17 Commits

Author SHA1 Message Date
Nikhil Soni
3942c8dede test(traces-qb): correct the scope declared-path doc comment 2026-08-25 20:06:52 +05:30
Nikhil Soni
3a9e6e78ad refactor(traces-qb): keep the resolved-column comment signal-agnostic
More than one home under one name is the general case; scope is one instance.
Also fold the two rewritten cases back onto single lines, as the rest of the
table is.

Assisted-by: Claude Opus 5
2026-08-25 19:58:55 +05:30
Nikhil Soni
bf27773965 test(traces-qb): expect scope names to union every home they resolve to
A `name`/`version` scope attribute used to be unreachable, so these cases
asserted that `scope.name` meant the declared path alone and that a bare
`name` never left the span column. Both now resolve, and a scope attribute
colliding with a name behaves like any other same-named attribute.

Renamed the three cases: their ids stated the old contract.

Assisted-by: Claude Opus 5
2026-08-25 19:45:26 +05:30
Nikhil Soni
4e06ba0230 refactor(traces-qb): inline the resolved-column lookup
The helper wrapped two lines behind a name that described the old
single-field behaviour. At the call site the fallback reads directly.

Assisted-by: Claude Opus 5
2026-08-25 19:21:06 +05:30
Nikhil Soni
cf5d9dafe1 Revert "test(traces-qb): cover semconv families for context-carrying select keys"
The extension was justified by resolving select through metadata before the
probe. That reordering is gone: a family match now behaves the same as it
always did, since logicalForResolvedColumn returned the family and returns a
list whose only element is that family. The extra key shapes pin nothing this
change alters.

Assisted-by: Claude Opus 5
2026-08-25 19:10:54 +05:30
Nikhil Soni
f665a1e0b9 test(traces-qb): pin the scope-prefixed name under an explicit context
A caller-supplied context means `scope.` is part of the name, not a prefix to
strip, so the key addresses a scope attribute literally named
`scope.testing.env`. Nothing covered the asymmetry with the normalized
spelling, which resolves to `testing.env`.

Assisted-by: Claude Opus 5
2026-08-25 18:45:12 +05:30
Nikhil Soni
73d2c5d09c refactor(traces-qb): keep every metadata match for a resolved column
Resolving select through metadata before the probe worked, but it reordered
resolution for every context-carrying key to fix one thing: a key that resolves
to a column is not necessarily one field.

Say that where it belongs instead. logicalForResolvedColumn already asks
metadata; it just threw away all but a family and fell back to the key itself.
Returning every match it finds fixes the same ambiguity without moving
resolution around: ColumnExpressionFor keeps its probe-first shape and differs
from main by a single line.

The scope column resolves for both a declared path and a same-named scope
attribute, so `scope.name` with such an attribute present now yields both homes
rather than one. Output is identical to the reordered version on every case
that motivated it -- declared-only, attribute-shadowed, materialized attribute,
and an undeclared scope attribute.

Assisted-by: Claude Opus 5
2026-08-25 17:16:41 +05:30
Nikhil Soni
3425776ca6 test(traces-qb): cover semconv families for context-carrying select keys
TestColumnExpressionForFamilyGroupBy only exercised a bare key, which resolves
through the candidate path. A key carrying a context now resolves through
metadata instead, and nothing covered that the family is still reached there.

Run the existing assertion over all three key shapes. The expression is
identical for each: MatchingLogicalFields returns the grouped family directly,
which is what logicalForResolvedColumn was selecting from the same call.

Assisted-by: Claude Opus 5
2026-08-25 16:17:57 +05:30
Nikhil Soni
39b2fc5cef refactor(traces-qb): restore CandidateKeys metadata match to main's form
The context-filtered bare-name match and the appended `{context}.{name}`
spelling existed to let a scope key find its declared path once getColumn
declined scope `name`/`version`. Resolving select through metadata first moved
that job to MatchingLogicalFields, so the extra matching is dead weight: the
suite is green with main's two straight lookups restored.

Keep the scope synthesize case -- it is not redundant. A scope attribute
metadata does not know still reaches CandidateKeys from the filter path, which
passes nil keys for strict contexts, and without the case it resolves to
nothing and the filter fails with "key not found". No test covered that, so
add one.

Assisted-by: Claude Opus 5
2026-08-25 12:44:58 +05:30
Nikhil Soni
bae9a15f2a fix(traces-qb): ask metadata before the column probe when selecting
ColumnExpressionFor resolved a key by probing FieldFor first, while the filter
path asks metadata first. The probe only answers whether a key resolves to a
column, and was standing in for whether it names one field. For a JSON column
that resolves for either of two homes -- a declared scope path or a same-named
scope attribute -- it reported a single resolved field for an ambiguous name,
so getColumn had to decline scope `name` and `version` to force the key back
onto the candidate path. That made a scope attribute so named unselectable:
it failed with "field not found".

Ask metadata first for a key that carries a context, the way the filter path
does. A bare key is left exactly as it was -- it cannot resolve through the
probe at all, since getColumn needs a context, so it already reaches
CandidateKeys, which consults metadata itself.

The filter path is untouched: its rule that one home wins an ambiguous name is
a deliberate choice, and selecting still coalesces the homes instead.

Assisted-by: Claude Opus 5
2026-08-24 12:40:01 +05:30
Nikhil Soni
3871692b3d fix: resolve declared scope paths and dual-context keys in CandidateKeys
The metadata match in CandidateKeys context-filtered every match, dropping a
bare key's cross-context metadata (a name present in both attribute and
resource resolved to attributes only) and, for a specified context, discarding
the literal {context}.{name} spelling (e.g. an attribute named span.test was
unreachable under span context). Filter only the bare-name matches by context,
take the compound-name match as-is, and keep the unfiltered fallback.

The scope synth branch no longer special-cases the declared scope paths:
getTracesKeys already surfaces scope.name / scope.version as intrinsic keys, so
they resolve through the metadata match; synth now only handles undeclared scope
attributes.

Assisted-by: Claude Opus 4.8
2026-08-21 20:41:07 +05:30
Nikhil Soni
5a7a36b83f fix: fix context check for matched keys 2026-08-21 19:20:12 +05:30
Nikhil Soni
c5264c617f refactor: check for context in key match 2026-08-21 18:55:58 +05:30
Nikhil Soni
2a88baae4f fix: prefer context prefixed keys over bare names if available 2026-08-21 15:03:11 +05:30
Nikhil Soni
e3f9628c5a chore: remove unnecessary redirection with small methods 2026-08-21 12:48:47 +05:30
Nikhil Soni
545ba1d64d fix: add context.name in the key selectors for select clause 2026-08-20 21:18:51 +05:30
Nikhil Soni
f5b6ec6f28 fix: restrict direct query on attributes with same name as declared path 2026-08-20 20:08:01 +05:30
6 changed files with 197 additions and 141 deletions

View File

@@ -163,31 +163,15 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
}
for idx := range query.GroupBy {
groupBy := query.GroupBy[idx]
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: groupBy.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: groupBy.FieldContext,
FieldDataType: groupBy.FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.GroupBy[idx].TelemetryFieldKey)...)
}
for idx := range query.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: query.SelectFields[idx].Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: query.SelectFields[idx].FieldContext,
FieldDataType: query.SelectFields[idx].FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.SelectFields[idx])...)
}
for idx := range query.Order {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: query.Order[idx].Key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: query.Order[idx].Key.FieldContext,
FieldDataType: query.Order[idx].Key.FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
}
for idx := range keySelectors {
@@ -198,6 +182,26 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
return keySelectors
}
func keySelectorsForField(key telemetrytypes.TelemetryFieldKey) []*telemetrytypes.FieldKeySelector {
selectors := []*telemetrytypes.FieldKeySelector{
{
Name: key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
},
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: key.FieldDataType,
})
}
return selectors
}
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
// definitions to the keys map. We do this during statement building, not at
// metadata fetch time, because:

View File

@@ -934,13 +934,63 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// A scope attribute whose own name literally carries a `scope.` prefix (`scope.prefixed`,
// normalized to {prefixed, scope}) resolves to that attribute in a SELECT even without a
// filter: getKeySelectors emits the reconstructed `scope.prefixed` selector so the metadata
// fetch surfaces it and AdjustKey recovers the full name. Without it the `scope.` prefix is
// lost and it wrongly reads `scope.attributes.prefixed`.
name: "scope-prefixed attribute in selectFields resolves without a filter",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.prefixed": {
{
Name: "scope.prefixed",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "prefixed", FieldContext: telemetrytypes.FieldContextScope},
},
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.`scope.prefixed` IS NOT NULL, scope.attributes.`scope.prefixed`::String, NULL) AS `__SELECT_KEY_3_scope.prefixed` 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},
},
},
{
// A scope-context key whose name matches a declared scope path resolves to that
// declared path (scope.name), not the span `name` column and not an undeclared
// scope attribute, even with no metadata.
name: "scope-context name with no metadata resolves to the declared scope path",
// scope attribute. getTracesKeys surfaces the declared path as an intrinsic key
// (metadata.go), which shadows the same-named span intrinsic.
name: "scope-context name resolves to the declared scope path",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {
{
Name: "scope.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"name": {
{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
@@ -956,40 +1006,22 @@ 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",
// span.scope.name (span context, name "scope.name") resolves to the declared
// scope path scope.name, not a span attribute literally named scope.name.
name: "span-context scope.name in selectFields resolves to the declared scope path",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.version": {
{
Name: "scope.version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"version": {
{
Name: "version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
{Name: "scope.name", FieldContext: telemetrytypes.FieldContextSpan},
},
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.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` 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

@@ -453,6 +453,19 @@ func TestConditionForScopeIntrinsicFields(t *testing.T) {
value: nil,
expectedSQL: "scope.version::String = ''",
},
{
// `scope.attribute.name` (normalized to {attribute.name, scope}) addresses the scope
// attribute named `name` — the declared `scope.name` path is never reached this way.
name: "Equal - scope.attribute.name reaches the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "attribute.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorEqual,
value: "io.signoz.checkout",
expectedSQL: "(scope.attributes.`name`::String = ? AND scope.attributes.`name` IS NOT NULL)",
},
}
for _, tc := range testCases {
@@ -491,6 +504,17 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
assert.Contains(t, args, "timeout")
})
t.Run("scope context with no metadata -> scope attribute", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "custom.attr", FieldContext: telemetrytypes.FieldContextScope}
conds, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, noMatches, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "v", sb)
assert.NoError(t, err, "an undeclared scope attribute must still be filterable")
assert.NotEmpty(t, warnings)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "scope.attributes.`custom.attr`")
})
t.Run("bare key with number operand -> attribute number", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "http.status"}

View File

@@ -300,16 +300,17 @@ 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 f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
// 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))
} 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))
attributeName := strings.TrimPrefix(key.Name, "attribute.") // literal "attribute" prefix in attribute keys needs double prefix
exprs = append(exprs, fmt.Sprintf("%s.attributes.%s::String", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
}
default:
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
@@ -352,20 +353,6 @@ func (m *fieldMapper) resolveColumnExprs(
return exprs, existExprs, columns, nil
}
// logicalForResolvedColumn returns the logical field for a directly-resolvable key: its
// semantic-convention family when the metadata map proves membership, otherwise the
// single-member field for the key as given.
func (m *fieldMapper) logicalForResolvedColumn(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) *telemetrytypes.LogicalField {
for _, logical := range querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys) {
if logical.IsFamily() &&
logical.FieldContext == field.FieldContext &&
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || logical.FieldDataType == field.FieldDataType) {
return logical
}
}
return telemetrytypes.SingleLogicalField(field.Name, field)
}
// upgradeToFamilies swaps single-member candidates for their family when the
// metadata map proves membership. Candidate order and every non-family
// candidate stay exactly as the legacy flow produced them; sibling candidates
@@ -428,38 +415,21 @@ 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)
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// Every match from metadata is kept, similar to the filter path.
candidates = querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
if len(candidates) == 0 {
candidates = querybuilder.WrapAsLogicalFields(field.Name, m.CandidateKeys(ctx, orgID, field, nil, keys))
candidates = []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(field.Name, field)}
}
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))...)
case errors.Is(err, qbtypes.ErrColumnNotFound):
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
@@ -626,28 +596,14 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
case telemetrytypes.FieldContextScope:
// A short scope name that names a declared scope path (e.g. {name, scope} -> scope.name)
// resolves to that declared path, not an undeclared scope attribute.
if compound := field.FieldContext.StringValue() + "." + field.Name; isDeclaredScopePath(compound) {
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(compound, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
return []*telemetrytypes.TelemetryFieldKey{synthScopeAttributeKey(field)}
// Declared scope paths (scope.name / scope.version) arrive via metadata as intrinsics;
// anything reaching synth is an undeclared scope attribute.
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
return nil
}
// synthScopeAttributeKey guesses a scope attribute (scope.attributes.<name>) for a name absent
// from metadata — the scope analog of querybuilder.SynthesizeKeys.
func synthScopeAttributeKey(field *telemetrytypes.TelemetryFieldKey) *telemetrytypes.TelemetryFieldKey {
return telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)
}
func isDeclaredScopePath(name string) bool {
f, ok := IntrinsicFields[name]
return ok && f.FieldContext == telemetrytypes.FieldContextScope
}
// 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 +611,7 @@ func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpre
return "", false
}
// Declared String paths are non-Nullable (absent reads '' not NULL).
if isDeclaredScopePath(key.Name) {
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
if exists {
return fieldExpression + " <> ''", true
}

View File

@@ -111,6 +111,18 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "scope.attributes.`custom.attr`::String",
expectedError: nil,
},
{
// `scope.attribute.name` normalizes to {attribute.name, scope}; the literal
// `attribute.` prefix is dropped so it addresses the scope attribute named `name`
// (which the declared `scope.name` path deliberately does not).
name: "Scope field - attribute prefix addresses the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "attribute.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.attributes.`name`::String",
expectedError: nil,
},
{
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
@@ -332,12 +344,13 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
})
}
// TestColumnExpressionForScopeUnion covers select-side resolution of scope names that
// TestColumnExpressionForScopeDeclaredPath covers select-side resolution of scope names that
// collide with a declared scope path. A short name under scope context (or the bare
// `scope.<x>` spelling that normalizes to it) binds to the declared path, and unions a
// same-named scope attribute when one is also in metadata. The full `scope.<x>` name under
// explicit scope context addresses the declared path alone.
func TestColumnExpressionForScopeUnion(t *testing.T) {
// `scope.<x>` spelling that normalizes to it) names both homes and coalesces them when
// metadata knows a same-named scope attribute, and binds to the declared path alone when it
// does not. The full `scope.<x>` name under explicit scope context addresses the declared
// path alone; the explicit `scope.attribute.` prefix addresses the attribute alone.
func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
ctx := context.Background()
scopeKey := func(name string) *telemetrytypes.TelemetryFieldKey {
@@ -371,12 +384,6 @@ func TestColumnExpressionForScopeUnion(t *testing.T) {
keys: declaredOnly,
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "short name unions the declared path and 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)",
},
{
name: "full scope.version name under scope context addresses the declared path alone",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope},
@@ -384,16 +391,42 @@ 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: "full scope.name name under scope context addresses the declared path alone",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
},
{
// `scope.attribute.name` normalizes to {attribute.name, scope}; the `attribute.`
// prefix is dropped so it addresses the scope attribute named `name` — the only
// way to reach it, since `scope.name` is reserved for the declared path.
name: "attribute prefix reaches the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{Name: "attribute.name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, NULL)",
},
{
// the caller supplied the context, so `scope.` is part of the name rather than a
// prefix to strip: this addresses a scope attribute literally named
// `scope.testing.env`, not the attribute `testing.env`
name: "explicit context keeps a scope-prefixed name intact",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.testing.env", FieldContext: telemetrytypes.FieldContextScope},
keys: declaredOnly,
expectedResult: "multiIf(scope.attributes.`scope.testing.env` IS NOT NULL, scope.attributes.`scope.testing.env`::String, NULL)",
},
{
// metadata knows both homes under this name, so the short spelling coalesces
// them instead of being rejected as ambiguous
name: "short name coalesces a known scope attribute with the declared path",
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)",
},
{
name: "full scope.name name under scope context addresses the declared path alone",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.name", FieldContext: telemetrytypes.FieldContextScope},
name: "short version coalesces a known scope attribute with the declared path",
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
expectedResult: "multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL)",
},
}

View File

@@ -1308,18 +1308,24 @@ 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"),
# 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';
# span 0's scope.name field equals it too but is NOT matched.
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_excludes_scope_name_field"),
# `scope.name` names both homes it can resolve to: the declared scope.name field
# (span 0) and a same-named `name` scope attribute (span 1), the same way any
# other name colliding across contexts unions. `scope.attribute.name` addresses
# the attribute alone.
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_unions_attribute"),
# The `scope.name` spelling is also a real stored key: span 2 carries a span
# attribute literally named `scope.name`, so it matches too.
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_matches_stored_spelling"),
# The explicit `scope.attribute.` prefix addresses the scope attribute alone, without
# the declared path. Span 1 has a `name` scope attribute = 'io.signoz.checkout'.
pytest.param("scope.attribute.name = 'io.signoz.checkout'", [1], id="scope_attribute_name"),
# `version` as a scope attribute: no span carries one (span 1's 4.5.6 is the declared
# scope.version, not a scope attribute), so this matches nothing.
pytest.param("scope.attribute.version = '4.5.6'", [], id="scope_attribute_version_none"),
# An unprefixed `name` is checked in every applicable context: the span `name`
# column (span 2) and a `name` scope attribute (span 1). It does not reach the
# declared scope.name field (span 0), which only the `scope.` prefix addresses.
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_unions_scope_attribute"),
# A value that no resolvable key holds (scope.name/scope.version field,
# a `name`/`version` scope attribute, or a same-named attribute/resource)
# returns nothing.
@@ -1349,10 +1355,11 @@ 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`/`scope.version` name every home they resolve to: the declared JSON
sub-column and a same-named `name`/`version` scope attribute. The explicit
`scope.attribute.` prefix addresses the attribute alone.
- a bare `name` reaches the span `name` column and a `name` scope attribute, but
never the declared scope.name field.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()