Compare commits

...

4 Commits

Author SHA1 Message Date
Nikhil Soni
1fd1859919 test(queriercommon): update ambiguity warning fixtures for body example
Assisted-by: Claude Opus 4.8
2026-09-17 12:58:55 +05:30
Nikhil Soni
341366e3ee test(querybuilder): align resource/body conflict test with repo conventions
Table-driven with a full expected statement, matching the sibling statement
builder tests.

Assisted-by: Claude Opus 4.8
2026-09-17 11:59:56 +05:30
Nikhil Soni
4eb3dcb32d test(querybuilder): cover resource/body conflict at the statement builder
Also surface a body-context example in the ambiguity warning alongside the
attribute one.

Assisted-by: Claude Opus 4.8
2026-09-17 11:51:03 +05:30
Nikhil Soni
bb0afe3600 fix(querybuilder): prefer resource over any context for ambiguous filter keys
Generalize the resource-wins rule so a bare key that also lives in body or
scope collapses to resource, not just attribute — otherwise the surviving
candidate ANDs against the resource fingerprint CTE and matches nothing.

Assisted-by: Claude Opus 4.8
2026-09-17 11:15:41 +05:30
4 changed files with 143 additions and 17 deletions

View File

@@ -25,8 +25,9 @@ const (
// ResolveLogicalFields picks which logical fields a filter term builds conditions
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
// name is ambiguous (several logical fields — a family is one field and never
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
// to the resource fields (the common intent), noted in the warning.
// ambiguous with itself) it returns a warning; a resource + other-context mix
// (attribute, body, scope, …) defaults to the resource fields (the common
// intent), noted in the warning.
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
if len(logicalFields) <= 1 {
return logicalFields, ""
@@ -39,18 +40,17 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
logicalFields,
)
hasResource, hasAttribute := false, false
hasResource, hasOther := false, false
for _, item := range logicalFields {
switch item.FieldContext {
case telemetrytypes.FieldContextResource:
if item.FieldContext == telemetrytypes.FieldContextResource {
hasResource = true
case telemetrytypes.FieldContextAttribute:
hasAttribute = true
} else {
hasOther = true
}
}
// when there is both resource and attribute context, default to resource only
if hasResource && hasAttribute {
// with resource and any other context, default to resource only
if hasResource && hasOther {
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
for _, item := range logicalFields {
if item.FieldContext == telemetrytypes.FieldContextResource {
@@ -58,8 +58,8 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
}
}
logicalFields = filtered
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
warning += " " + "Using `resource` context by default. To query another context explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s' or 'body.%s')", field.Name, field.Name)
}
return logicalFields, warning

View File

@@ -175,6 +175,42 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
}
// Resource wins over every other context, not just attribute: a bare key that
// also lives in body or scope must collapse to resource alone, so the surviving
// candidate does not AND against the resource fingerprint CTE.
func TestResolveLogicalFieldsResourceWinsOverOtherContexts(t *testing.T) {
testCases := []struct {
name string
other telemetrytypes.FieldContext
}{
{name: "ResourceOverBody", other: telemetrytypes.FieldContextBody},
{name: "ResourceOverScope", other: telemetrytypes.FieldContextScope},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
requested := &telemetrytypes.TelemetryFieldKey{Name: "service.name"}
fields := []*telemetrytypes.LogicalField{
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}),
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: testCase.other,
FieldDataType: telemetrytypes.FieldDataTypeString,
}),
}
resolved, warning := ResolveLogicalFields(requested, fields)
assert.NotEmpty(t, warning)
require.Len(t, resolved, 1)
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
})
}
}
// Members of a family with different data types never merge: the identity
// (signal, context, data type) separates them into distinct logical fields.
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {

View File

@@ -0,0 +1,90 @@
package logsstatementbuilder
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
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/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// A key present in both resource and body contexts must filter on resource only.
// The resource condition builds the fingerprint CTE, so a surviving body condition
// would AND against it and match almost nothing (engineering-pod#6086).
func TestStatementBuilderResourceBodyConflict(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
store.SetStaticFields(logstelemetryschema.IntrinsicFields)
store.SetKey(&telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
bodyKey := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
require.NoError(t, bodyKey.SetJSONAccessPlan(telemetrytypes.JSONColumnMetadata{
BaseColumn: logstelemetryschema.LogsV2BodyV2Column,
PromotedColumn: logstelemetryschema.LogsV2BodyPromotedColumn,
}, map[string][]telemetrytypes.FieldDataType{"service.name": {telemetrytypes.FieldDataTypeString}}))
store.SetKey(bodyKey)
fl := flaggertest.WithUseJSONBody(t, true)
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store,
storage,
aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn,
fl,
nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "AmbiguousKeyFiltersResourceOnly",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "service.name = 'webapp'"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_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, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"webapp", "%service.name%", "%service.name\":\"webapp%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{
"Key `service.name` is ambiguous, found 2 different combinations of field context / data type: [name=service.name,context=resource,datatype=string name=service.name,context=body,datatype=string]. Using `resource` context by default. To query another context explicitly, use the fully qualified name (e.g., 'attribute.service.name' or 'body.service.name')",
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected.Query, q.Query)
require.Equal(t, testCase.expected.Args, q.Args)
require.Equal(t, testCase.expected.Warnings, q.Warnings)
})
}
}

View File

@@ -64,8 +64,8 @@ def test_resource_default_warning(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
assert warning["warnings"] == [
{"message": expected_service_name_warning},
@@ -237,8 +237,8 @@ def test_deduped_warnings_for_single_query(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
assert warning["warnings"] == [
@@ -328,8 +328,8 @@ def test_deduped_warnings_for_multiple_queries(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
assert warning["warnings"] == [