Compare commits

...

3 Commits

Author SHA1 Message Date
Nityananda Gohain
578e4644e1 Merge branch 'main' into issue_4785 2026-04-29 16:45:48 +05:30
Piyush Singariya
a672335a33 fix: Body Search warning with FTS in JSON Logs (#10807)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: fts warning miss in direct text search

* fix: comments

* test: added one more test variation

* ci: go lint

* fix: fts warning update

* fix: integration tests

* fix: go test and fmtlint
2026-04-29 08:50:28 +00:00
nityanandagohain
afee062eaf fix: handle series with different types of labels 2026-04-27 13:00:48 +05:30
6 changed files with 131 additions and 31 deletions

View File

@@ -627,19 +627,29 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
return &qbtypes.ScalarData{QueryName: queryName}
}
columns := []*qbtypes.ColumnDescriptor{}
// Add group columns from first series
if len(tsData.Aggregations[0].Series) > 0 {
for _, label := range tsData.Aggregations[0].Series[0].Labels {
columns = append(columns, &qbtypes.ColumnDescriptor{
TelemetryFieldKey: label.Key,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
})
// Series can have ragged label sets; build the column schema from the
// union of all label keys (first-seen order) and fill rows by key lookup.
keyOrder := []telemetrytypes.TelemetryFieldKey{}
keyIndex := map[string]int{}
for _, series := range tsData.Aggregations[0].Series {
for _, label := range series.Labels {
if _, ok := keyIndex[label.Key.Name]; ok {
continue
}
keyIndex[label.Key.Name] = len(keyOrder)
keyOrder = append(keyOrder, label.Key)
}
}
columns := make([]*qbtypes.ColumnDescriptor, 0, len(keyOrder)+len(tsData.Aggregations))
for _, key := range keyOrder {
columns = append(columns, &qbtypes.ColumnDescriptor{
TelemetryFieldKey: key,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
})
}
// Add aggregation columns
for _, agg := range tsData.Aggregations {
name := agg.Alias
@@ -655,18 +665,18 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
})
}
// Build rows
// Build rows.
groupColCount := len(keyOrder)
data := [][]any{}
for seriesIdx, series := range tsData.Aggregations[0].Series {
row := make([]any, len(columns))
// Add group values
for i, label := range series.Labels {
row[i] = label.Value
// Place each label under its key's column (by lookup, not index).
for _, label := range series.Labels {
row[keyIndex[label.Key.Name]] = label.Value
}
// Add aggregation values (last value)
groupColCount := len(series.Labels)
for aggIdx, agg := range tsData.Aggregations {
if seriesIdx < len(agg.Series) && len(agg.Series[seriesIdx].Values) > 0 {
lastValue := agg.Series[seriesIdx].Values[len(agg.Series[seriesIdx].Values)-1].Value

View File

@@ -0,0 +1,53 @@
package querier
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
label := func(name string, value any) *qbtypes.Label {
return &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: name},
Value: value,
}
}
series := func(labels []*qbtypes.Label, value float64) *qbtypes.TimeSeries {
return &qbtypes.TimeSeries{
Labels: labels,
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1, Value: value}},
}
}
tsData := &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Index: 0,
Series: []*qbtypes.TimeSeries{
series([]*qbtypes.Label{label("label_1", "orphan-0")}, 20),
series([]*qbtypes.Label{label("label_1", "box-0"), label("label_2", "rpc-0")}, 10),
},
}},
}
var sd *qbtypes.ScalarData
require.NotPanics(t, func() {
sd = convertTimeSeriesDataToScalar(tsData, "A")
})
require.NotNil(t, sd)
require.Len(t, sd.Columns, 3)
assert.Equal(t, "label_1", sd.Columns[0].Name)
assert.Equal(t, "label_2", sd.Columns[1].Name)
assert.Equal(t, "__result_0", sd.Columns[2].Name)
require.Len(t, sd.Data, 2)
assert.Equal(t, []any{"orphan-0", nil, 20.0}, sd.Data[0])
assert.Equal(t, []any{"box-0", "rpc-0", 10.0}, sd.Data[1])
}

View File

@@ -4,6 +4,10 @@ const (
TrueConditionLiteral = "true"
SkipConditionLiteral = "__skip__"
ErrorConditionLiteral = "__skip_because_of_error__"
// BodyFullTextSearchDefaultWarning is emitted when a full-text search or "body" searches are hit
// with New JSON Body enhancements.
BodyFullTextSearchDefaultWarning = "Full text searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
)
var (

View File

@@ -362,6 +362,10 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
}
return cond
}
@@ -717,6 +721,10 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
}
return cond
}

View File

@@ -894,12 +894,12 @@ func TestAdjustKey(t *testing.T) {
func TestStmtBuilderBodyField(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
enableUseJSONBody bool
expected qbtypes.Statement
expectedErr error
expected qbtypes.Statement
expectedErr error
}{
{
name: "body_exists",
@@ -1039,15 +1039,15 @@ func TestStmtBuilderBodyField(t *testing.T) {
func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
enableUseJSONBody bool
expected qbtypes.Statement
expectedErr error
expected qbtypes.Statement
expectedErr error
}{
{
name: "body_contains",
name: "fts",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -1056,13 +1056,30 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
},
enableUseJSONBody: true,
expected: qbtypes.Statement{
Query: "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 match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "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 match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "body_contains_disabled",
name: "fts_2",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "error"},
Limit: 10,
},
enableUseJSONBody: true,
expected: qbtypes.Statement{
Query: "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 match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "fts_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,

View File

@@ -1212,13 +1212,21 @@ def test_message_searches(
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and set(_body_messages(r)) == payment_messages,
},
# FTS — bare keyword
# FTS — String bare keyword
{
"name": "msg.fts_quoted",
"requestType": "raw",
"expression": '"Payment"',
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)),
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
},
# FTS — bare keyword
{
"name": "msg.fts_quoted_without_quotes",
"requestType": "raw",
"expression": "Payment",
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
},
# = operator via body.message — tests exact match path
{