Compare commits

..

11 Commits

Author SHA1 Message Date
Tushar Vats
3030c3f480 chore(openapi): regenerate frontend clients
Assisted-by: Claude Opus 5.5
2026-11-06 23:36:52 +05:30
Tushar Vats
07753f7902 fix(fields): filter int64 and float64 values by search text
Assisted-by: Claude Opus 5.5
2026-11-06 23:31:16 +05:30
Tushar Vats
d30f2113ac test(quickfilter): keep only non-obvious behaviour in docstrings
Assisted-by: Claude Opus 5.5
2026-11-06 23:21:18 +05:30
Tushar Vats
2885b6bd86 refactor(fields): name receivers after their role
Assisted-by: Claude Opus 5.5
2026-11-06 23:18:36 +05:30
Tushar Vats
28768cdd16 fix(fields): keep bools and duplicates out of the merged values limit
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
4bf38c3680 fix(fields): drop empty related values before the limit
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
5d5c887eac test(quickfilter): cover field keys and values name resolution
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
9bfa1a65ec chore(openapi): regenerate specs
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
c0d57149d3 fix(fields): treat resource prefix as a context keyword
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
27628fca5c chore(openapi): regenerate specs
Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
Tushar Vats
692a626447 fix(fields): resolve field value names like the query builder
A name with a context prefix looks up both readings, exact name first,
since the prefix may be part of the stored name. An explicit fieldContext
takes the name as is so every key stays addressable.

Assisted-by: Claude Opus 5.5
2026-11-06 23:16:40 +05:30
61 changed files with 991 additions and 3098 deletions

View File

@@ -15996,13 +15996,23 @@ paths:
where it is case-sensitive and % and _ act as wildcards. For keys, a context
prefix such as resource.service.name sets fieldContext when it is empty.'
type: string
- description: The key whose values to return, such as service.name. A context
prefix such as resource.service.name sets fieldContext when it is empty.
- description: 'The key whose values to return, such as service.name. With fieldContext
set, it is the stored name as is. Otherwise a context prefix such as resource.service.name
sets the context and a type suffix such as service.name:string sets fieldDataType
when that is empty; as in filter expressions, a prefixed name other than
resource. also matches a key stored under the full name, whose values come
first: log.level returns values of a key named log.level, then of the log
field level.'
in: query
name: name
schema:
description: The key whose values to return, such as service.name. A context
prefix such as resource.service.name sets fieldContext when it is empty.
description: 'The key whose values to return, such as service.name. With
fieldContext set, it is the stored name as is. Otherwise a context prefix
such as resource.service.name sets the context and a type suffix such
as service.name:string sets fieldDataType when that is empty; as in filter
expressions, a prefixed name other than resource. also matches a key stored
under the full name, whose values come first: log.level returns values
of a key named log.level, then of the log field level.'
type: string
- description: Filter expression already applied in the explorer. When set,
relatedValues lists the values seen on data matching it.
@@ -28612,13 +28622,23 @@ paths:
where it is case-sensitive and % and _ act as wildcards. For keys, a context
prefix such as resource.service.name sets fieldContext when it is empty.'
type: string
- description: The key whose values to return, such as service.name. A context
prefix such as resource.service.name sets fieldContext when it is empty.
- description: 'The key whose values to return, such as service.name. With fieldContext
set, it is the stored name as is. Otherwise a context prefix such as resource.service.name
sets the context and a type suffix such as service.name:string sets fieldDataType
when that is empty; as in filter expressions, a prefixed name other than
resource. also matches a key stored under the full name, whose values come
first: log.level returns values of a key named log.level, then of the log
field level.'
in: query
name: name
schema:
description: The key whose values to return, such as service.name. A context
prefix such as resource.service.name sets fieldContext when it is empty.
description: 'The key whose values to return, such as service.name. With
fieldContext set, it is the stored name as is. Otherwise a context prefix
such as resource.service.name sets the context and a type suffix such
as service.name:string sets fieldDataType when that is empty; as in filter
expressions, a prefixed name other than resource. also matches a key stored
under the full name, whose values come first: log.level returns values
of a key named log.level, then of the log field level.'
type: string
- description: Filter expression already applied in the explorer. When set,
relatedValues lists the values seen on data matching it.

View File

@@ -15352,7 +15352,7 @@ export type GetFieldsValuesParams = {
searchText?: string;
/**
* @type string
* @description The key whose values to return, such as service.name. A context prefix such as resource.service.name sets fieldContext when it is empty.
* @description The key whose values to return, such as service.name. With fieldContext set, it is the stored name as is. Otherwise a context prefix such as resource.service.name sets the context and a type suffix such as service.name:string sets fieldDataType when that is empty; as in filter expressions, a prefixed name other than resource. also matches a key stored under the full name, whose values come first: log.level returns values of a key named log.level, then of the log field level.
*/
name?: string;
/**
@@ -16957,7 +16957,7 @@ export type GetRuleHistoryFilterValuesParams = {
searchText?: string;
/**
* @type string
* @description The key whose values to return, such as service.name. A context prefix such as resource.service.name sets fieldContext when it is empty.
* @description The key whose values to return, such as service.name. With fieldContext set, it is the stored name as is. Otherwise a context prefix such as resource.service.name sets the context and a type suffix such as service.name:string sets fieldDataType when that is empty; as in filter expressions, a prefixed name other than resource. also matches a key stored under the full name, whose values come first: log.level returns values of a key named log.level, then of the log field level.
*/
name?: string;
/**

View File

@@ -199,8 +199,7 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
// fixed gen_ai keys that many traces lack, so a not-found warning means nothing here
expr, _, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
if err != nil {
return nil, err
}

View File

@@ -170,13 +170,6 @@ func (q *querier) QueryRangePreview(
q.runPreviewTasks(ctx, previewTasks, results)
// a filter and a column on the same unknown key report the same warning;
// QueryRange dedupes before it builds its response too
for name, ps := range results {
ps.Warnings = orEmpty(dedupeWarnings(ps.Warnings))
results[name] = ps
}
return &qbtypes.QueryRangePreviewResponse{
CompositeQuery: results,
}, nil

View File

@@ -5,22 +5,17 @@ import (
"fmt"
"log/slog"
"strings"
"unicode"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/antlr4-go/antlr/v4"
"github.com/huandu/go-sqlbuilder"
)
const argumentPlaceholderPrefix = "__signoz_argument_"
type aggExprRewriter struct {
logger *slog.Logger
fullTextColumn *telemetrytypes.TelemetryFieldKey
@@ -60,28 +55,27 @@ func (r *aggExprRewriter) Rewrite(
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, []any, []string, error) {
) (string, []any, error) {
arguments := map[string]string{}
wrapped := fmt.Sprintf("SELECT %s", extractArguments(expr, arguments))
wrapped := fmt.Sprintf("SELECT %s", expr)
p := chparser.NewParser(wrapped)
stmts, err := p.ParseStmts()
if err != nil {
return "", nil, nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
return "", nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse aggregation expression %q", expr)
}
if len(stmts) == 0 {
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
return "", nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
}
sel, ok := stmts[0].(*chparser.SelectQuery)
if !ok {
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
return "", nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
}
if len(sel.SelectItems) == 0 {
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
}
visitor := &exprVisitor{
@@ -91,17 +85,16 @@ func (r *aggExprRewriter) Rewrite(
fieldKeys: keys,
fullTextColumn: r.fullTextColumn,
storage: r.storage,
arguments: arguments,
}
// Rewrite the first select item (our expression)
if err := sel.SelectItems[0].Accept(visitor); err != nil {
return "", nil, nil, err
return "", nil, err
}
if visitor.isRate {
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, visitor.warnings, nil
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, nil
}
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, visitor.warnings, nil
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, nil
}
// RewriteMulti rewrites a slice of expressions.
@@ -113,26 +106,24 @@ func (r *aggExprRewriter) RewriteMulti(
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) ([]string, [][]any, []string, error) {
) ([]string, [][]any, error) {
out := make([]string, len(exprs))
var errs []error
var chArgsList [][]any
var warnings []string
for i, e := range exprs {
w, chArgs, exprWarnings, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e
} else {
out[i] = w
chArgsList = append(chArgsList, chArgs)
warnings = append(warnings, exprWarnings...)
}
}
if len(errs) > 0 {
return out, nil, nil, errors.Join(errs...)
return out, nil, errors.Join(errs...)
}
return out, chArgsList, warnings, nil
return out, chArgsList, nil
}
// exprVisitor walks FunctionExpr nodes and resolves and renders their
@@ -145,10 +136,8 @@ type exprVisitor struct {
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
fullTextColumn *telemetrytypes.TelemetryFieldKey
storage qbtypes.Storage
arguments map[string]string
Modified bool
chArgs []any
warnings []string
isRate bool
}
@@ -166,16 +155,10 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
args = fn.Params.Items.Items
}
// count(*) is count()
if len(args) == 1 && aggFunc.MinArgs == 0 && v.arguments[chparser.Format(args[0])] == "*" {
fn.Params.Items.Items = nil
args = nil
}
// if we know aggregation function, we must ensure that the number of arguments is correct
if aggFunc.RequireArgs {
if len(args) < aggFunc.MinArgs || len(args) > aggFunc.MaxArgs {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid number of arguments for %q: %d", name, len(args))
return errors.NewInternalf(errors.CodeInternal, "invalid number of arguments for %q: %d", name, len(args))
}
}
fn.Name.Name = aggFunc.FuncName
@@ -191,10 +174,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
// Handle *If functions with predicate + values
if aggFunc.FuncCombinator {
// Map the predicate (last argument)
origPred, err := v.argumentText(name, args[len(args)-1])
if err != nil {
return err
}
origPred := chparser.Format(args[len(args)-1])
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
@@ -209,11 +189,6 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
if err != nil {
return err
}
// The search scan budget applies only to the filter.
if whereClause.RequiresCostGuard {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `search` is not supported inside an aggregation").WithSuggestions("use `search` in the filter")
}
v.warnings = append(v.warnings, whereClause.Warnings...)
// not possible for whereClause to be empty here but still adding a check.
if whereClause.IsEmpty() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid predicate argument for %q: %q", name, origPred)
@@ -227,23 +202,16 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
}
args[len(args)-1] = parsedPred
v.Modified = true
v.chArgs = append(v.chArgs, chArgs...)
v.chArgs = chArgs
// Map each value column argument
for i := 0; i < len(args)-1; i++ {
origVal, err := v.argumentText(name, args[i])
if err != nil {
return err
}
fieldKey, err := fieldKeyFromArgument(name, origVal)
if err != nil {
return err
}
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
origVal := chparser.Format(args[i])
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
}
v.warnings = append(v.warnings, warnings...)
newVal := sqlbuilder.Escape(expr)
parsedVal, err := parseFragment(newVal)
if err != nil {
@@ -255,19 +223,12 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
} else {
// Non-If functions: map every argument as a column/value
for i, arg := range args {
orig, err := v.argumentText(name, arg)
orig := chparser.Format(arg)
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
if err != nil {
return err
}
fieldKey, err := fieldKeyFromArgument(name, orig)
if err != nil {
return err
}
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
if err != nil {
return err
}
v.warnings = append(v.warnings, warnings...)
newCol := sqlbuilder.Escape(expr)
parsed, err := parseFragment(newCol)
if err != nil {
@@ -284,14 +245,6 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
return nil
}
func (v *exprVisitor) argumentText(name string, arg chparser.Expr) (string, error) {
text, ok := v.arguments[chparser.Format(arg)]
if !ok {
return "", errors.NewInternalf(errors.CodeInternal, "argument of %q was not extracted before parsing", name)
}
return text, nil
}
// parseFragment parses a SQL expression fragment by wrapping in SELECT.
func parseFragment(sql string) (chparser.Expr, error) {
wrapped := fmt.Sprintf("SELECT %s", sql)
@@ -309,134 +262,3 @@ func parseFragment(sql string) (chparser.Expr, error) {
}
return sel.SelectItems[0].Expr, nil
}
// extractArguments replaces every argument of an aggregation call with a
// placeholder kept in arguments, because the ClickHouse parser cannot read
// the predicates such as EXISTS or CONTAINS, and field keys
// with hyphens or slashes. Unbalanced input is returned unchanged for the
// parser to reject.
func extractArguments(expr string, arguments map[string]string) string {
var out strings.Builder
written := 0
for i := 0; i < len(expr); i++ {
c := expr[i]
if c == '\'' || c == '"' || c == '`' {
end := skipQuoted(expr, i)
if end < 0 {
return expr
}
i = end
continue
}
if !isIdentifierStart(c) || (i > 0 && (isIdentifierPart(expr[i-1]) || expr[i-1] == '.')) {
continue
}
nameEnd := i
for nameEnd < len(expr) && isIdentifierPart(expr[nameEnd]) {
nameEnd++
}
open := nameEnd
for open < len(expr) && strings.ContainsRune(" \t\r\n", rune(expr[open])) {
open++
}
if _, ok := AggreFuncMap[valuer.NewString(strings.ToLower(expr[i:nameEnd]))]; !ok || open == len(expr) || expr[open] != '(' {
i = nameEnd - 1
continue
}
commas, closing := scanArguments(expr, open)
if closing < 0 {
return expr
}
out.WriteString(expr[written : open+1])
bounds := append(append([]int{open}, commas...), closing)
for j := 1; j < len(bounds); j++ {
if j > 1 {
out.WriteString(", ")
}
text := strings.TrimSpace(expr[bounds[j-1]+1 : bounds[j]])
// DISTINCT modifies the whole argument list, so it stays for the parser.
if end := strings.IndexFunc(text, unicode.IsSpace); j == 1 && end > 0 && strings.EqualFold(text[:end], "DISTINCT") {
out.WriteString("DISTINCT ")
text = strings.TrimSpace(text[end:])
}
if text == "" {
continue
}
placeholder := fmt.Sprintf("%s%d", argumentPlaceholderPrefix, len(arguments))
arguments[placeholder] = text
out.WriteString(placeholder)
}
written = closing
i = closing
}
out.WriteString(expr[written:])
return out.String()
}
// scanArguments returns the top-level commas and the closing parenthesis of
// the argument list that opens at open, with -1 as the closing index when the
// list is unterminated.
func scanArguments(expr string, open int) ([]int, int) {
var commas []int
depth := 0
for i := open + 1; i < len(expr); i++ {
switch expr[i] {
case '\'', '"', '`':
end := skipQuoted(expr, i)
if end < 0 {
return nil, -1
}
i = end
case '(', '[':
depth++
case ']':
depth--
case ')':
if depth == 0 {
return commas, i
}
depth--
case ',':
if depth == 0 {
commas = append(commas, i)
}
}
}
return nil, -1
}
// fieldKeyFromArgument reads a value argument as a single key of the filter
// grammar.
func fieldKeyFromArgument(name string, text string) (telemetrytypes.TelemetryFieldKey, error) {
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(text))
lexer.RemoveErrorListeners()
if lexer.NextToken().GetTokenType() != grammar.FilterQueryLexerKEY || lexer.NextToken().GetTokenType() != antlr.TokenEOF {
return telemetrytypes.TelemetryFieldKey{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid argument for %q: %q is not a field name", name, text)
}
return telemetrytypes.GetFieldKeyFromKeyText(text), nil
}
// skipQuoted returns the index of the quote closing the one at start, honoring
// backslash escapes and doubled quotes, or -1 when it is unterminated.
func skipQuoted(expr string, start int) int {
quote := expr[start]
for i := start + 1; i < len(expr); i++ {
switch {
case expr[i] == '\\':
i++
case expr[i] == quote && i+1 < len(expr) && expr[i+1] == quote:
i++
case expr[i] == quote:
return i
}
}
return -1
}
func isIdentifierStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func isIdentifierPart(c byte) bool {
return isIdentifierStart(c) || (c >= '0' && c <= '9')
}

View File

@@ -111,8 +111,7 @@ func Resolve(
}
// ResolveColumn resolves one key for a select field, group by, order by, or
// aggregation, and renders its column expression. The warnings are the ones
// a filter on the same key reports.
// aggregation, and renders its column expression.
func ResolveColumn(
ctx context.Context,
q qbtypes.QueryInfo,
@@ -120,16 +119,12 @@ func ResolveColumn(
key *telemetrytypes.TelemetryFieldKey,
target telemetrytypes.FieldDataType,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, []string, error) {
) (string, error) {
resolved, err := Resolve(ctx, q, storage, key, qbtypes.FilterOperatorUnknown, nil, fieldKeys)
if err != nil {
return "", nil, err
return "", err
}
column, err := Column(ctx, q, storage, resolved, target)
if err != nil {
return "", nil, err
}
return column, resolved.Warnings, nil
return Column(ctx, q, storage, resolved, target)
}
// intrinsicColumnFirst puts a column every row has first for a bare key.

View File

@@ -1,86 +0,0 @@
package querybuilder_test
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
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/mock"
"github.com/stretchr/testify/require"
)
// A key metadata does not report warns and the query runs, so in a select
// field, group by, order by, or aggregation every storage's fallback must read.
// The only failure allowed is invalid input for a context the signal does not
// serve.
func TestResolveColumn_UnknownKey(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
start := uint64(releaseTime.Add(24 * time.Hour).UnixNano())
end := start + uint64(time.Hour.Nanoseconds())
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
testCases := []struct {
name string
storage qbtypes.Storage
query qbtypes.QueryInfo
}{
{
name: "Logs",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
{
name: "Logs_JSONBody",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end, BodyJSONOn: true},
},
{
name: "Traces",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end},
},
{
name: "Traces_JSONAttributes",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end, TraceAttrsJSONOn: true},
},
{
name: "Metrics",
storage: metricstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalMetrics, StartNs: start, EndNs: end},
},
{
name: "Audit",
storage: audittelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
}
for _, testCase := range testCases {
for _, value := range (telemetrytypes.FieldContext{}).Enum() {
fieldContext, ok := value.(telemetrytypes.FieldContext)
require.True(t, ok)
t.Run(testCase.name+"_"+fieldContext.StringValue(), func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "unknown.key", FieldContext: fieldContext, Signal: testCase.query.Signal}
_, _, err := querybuilder.ResolveColumn(context.Background(), testCase.query, testCase.storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
if err != nil {
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "%v", err)
}
})
}
}
}

View File

@@ -229,9 +229,8 @@ func (b *auditQueryStatementBuilder) buildListQuery(
) (*qbtypes.Statement, error) {
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -264,11 +263,10 @@ func (b *auditQueryStatementBuilder) buildListQuery(
continue
}
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(colExpr), query.SelectFields[index].Name))
}
}
@@ -281,11 +279,10 @@ func (b *auditQueryStatementBuilder) buildListQuery(
}
for _, orderBy := range query.Order {
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
}
@@ -307,7 +304,7 @@ func (b *auditQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -324,9 +321,8 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -346,11 +342,10 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
for _, gb := range query.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
sb.SelectMore(colExpr)
@@ -359,11 +354,10 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -449,7 +443,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -467,9 +461,8 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -485,11 +478,10 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
for _, gb := range query.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
sb.SelectMore(colExpr)
@@ -500,11 +492,10 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -559,7 +550,7 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -337,9 +337,8 @@ func (b *logQueryStatementBuilder) buildListQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -378,11 +377,10 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
// get column expression for the field - use array index directly to avoid pointer to loop variable
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", colExpr, selectColumnAlias(index, query.SelectFields[index].Name))))
}
}
@@ -398,11 +396,10 @@ func (b *logQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
}
@@ -425,7 +422,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}
@@ -444,9 +441,8 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -470,11 +466,10 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
}
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
@@ -484,7 +479,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
@@ -492,7 +487,6 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -595,7 +589,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}
@@ -616,9 +610,8 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -638,11 +631,10 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
}
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
@@ -656,7 +648,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
rateInterval,
keys,
@@ -664,7 +656,6 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -730,7 +721,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}

View File

@@ -148,8 +148,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
))
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -236,8 +235,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -313,8 +311,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
))
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}

View File

@@ -320,8 +320,7 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
sb.Select("fingerprint")
for i, g := range query.GroupBy {
// buildTimeSeriesCTE resolves the same keys and reports their warnings
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -545,16 +544,11 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, tsTable))
sb.Select("fingerprint")
var columnWarnings []string
for i, g := range query.GroupBy {
col, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, nil, err
}
// the histogram rewrite adds le itself, so its warning is not the caller's
if query.Aggregations[0].Type != metrictypes.HistogramType || !isHistogramBucket(g) {
columnWarnings = append(columnWarnings, keyWarnings...)
}
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", col, GroupByColumnAlias(i, g.Name))))
}
@@ -583,7 +577,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
// the caller joins this into another builder, which compiles it again
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, append(preparedWhereClause.Warnings, columnWarnings...), nil
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, preparedWhereClause.Warnings, nil
}
func (b *StatementBuilder) buildTemporalAggregationCTE(

View File

@@ -512,36 +512,6 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "Gauge_GroupByUnknownLe_Warns",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.memory.usage",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationMax,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "le",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `le`, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT * FROM __spatial_aggregation_cte ORDER BY `le`, ts",
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
Warnings: []string{"key `le` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
},
expectedErr: nil,
},
{
name: "test_histogram_percentile2",
requestType: qbtypes.RequestTypeTimeSeries,
@@ -601,8 +571,7 @@ func TestStatementBuilder(t *testing.T) {
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
// the filter and the group by each report the key; the response dedupes them
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos.", "key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
},
expectedErr: nil,
},

View File

@@ -36,8 +36,7 @@ func (r *columnResolver) ValueFor(ctx context.Context, q qbtypes.QueryInfo, key
if cands := r.keys[key.Name]; len(cands) > 0 {
key = cands[0]
}
// aggregate renders return no warnings
expr, _, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
expr, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
if err != nil {
return "", err
}

View File

@@ -478,21 +478,19 @@ func groupBySelectors(groupBy []qbtypes.GroupByKey) []*telemetrytypes.FieldKeySe
// resolveGroupColumns resolves group-by keys through the storage for selection
// inside the per-trace scan; keys must cover the group-by selectors.
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, []string, error) {
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, error) {
if len(groupBy) == 0 {
return nil, nil, nil
return nil, nil
}
out := make([]groupColumn, 0, len(groupBy))
var warnings []string
for i := range groupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, nil, err
return nil, err
}
warnings = append(warnings, keyWarnings...)
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
}
return out, warnings, nil
return out, nil
}
// ---------------------------------------------------------------------------
@@ -600,7 +598,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
}
}
groupCols, groupWarnings, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
groupCols, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
if err != nil {
return nil, err
}
@@ -736,7 +734,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
return &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(msc.warnings, groupWarnings...),
Warnings: msc.warnings,
WarningsDocURL: msc.warnURL,
}, nil
}

View File

@@ -362,9 +362,8 @@ func (b *traceQueryStatementBuilder) buildListQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -383,11 +382,10 @@ func (b *traceQueryStatementBuilder) buildListQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, field := range query.SelectFields {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
}
@@ -408,11 +406,10 @@ func (b *traceQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
}
@@ -435,7 +432,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -572,9 +569,8 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -600,11 +596,10 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, gb := range query.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
@@ -613,7 +608,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
@@ -621,7 +616,6 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -720,7 +714,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -741,9 +735,8 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
columnWarnings []string
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -767,11 +760,10 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, gb := range query.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
@@ -784,7 +776,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
rateInterval,
keys,
@@ -792,7 +784,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -858,7 +849,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -756,9 +756,8 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
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 `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_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},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("non-existent.key")},
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 `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_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,
},
@@ -927,9 +926,8 @@ 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.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.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},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.version")},
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_scope.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},
},
},
{
@@ -1019,9 +1017,8 @@ 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.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},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.name")},
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

@@ -462,7 +462,6 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
// Select core fields. These are always present so the trace operator
@@ -491,13 +490,12 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
if selectedFields[field.Name] {
continue
}
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
b.stmtBuilder.logger.WarnContext(ctx, "failed to map select field",
slog.String("field", field.Name), errors.Attr(err))
continue
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
selectedFields[field.Name] = true
}
@@ -513,11 +511,10 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
// Add order by support
orderApplied := false
for _, orderBy := range b.operator.Order {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
orderApplied = true
}
@@ -538,9 +535,8 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return &qbtypes.Statement{
Query: sql,
Args: args,
Warnings: columnWarnings,
Query: sql,
Args: args,
}, nil
}
@@ -626,7 +622,6 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
}
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
sb.Select(fmt.Sprintf(
@@ -636,7 +631,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -645,13 +640,12 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -668,7 +662,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -707,9 +700,8 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Warnings: columnWarnings,
Query: sql,
Args: args,
}, nil
}
@@ -730,14 +722,13 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
}
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
b.buildTraceSummaryCTE(selectFromCTE)
sb := sqlbuilder.NewSelectBuilder()
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -746,7 +737,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
@@ -754,7 +744,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -771,7 +761,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -860,19 +849,17 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Warnings: columnWarnings,
Query: sql,
Args: args,
}, nil
}
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -881,13 +868,12 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -904,7 +890,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -954,9 +939,8 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Warnings: columnWarnings,
Query: sql,
Args: args,
}, nil
}

View File

@@ -0,0 +1,91 @@
package telemetrymetadata
import (
"context"
"testing"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestGetAllValuesExactNameFirst(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := NewTelemetryMetaStore(instrumentationtest.New().ToProviderSettings(), mockTelemetryStore, flaggertest.New(t))
boolColumns := []cmock.ColumnType{{Name: "1", Type: "UInt8"}}
valueColumns := []cmock.ColumnType{{Name: "string_value", Type: "String"}, {Name: "number_value", Type: "Float64"}}
mock.ExpectQuery(`SELECT 1 FROM .*tag_key = \? AND tag_data_type = \? LIMIT`).
WithArgs("log.level", "bool", 1).
WillReturnRows(cmock.NewRows(boolColumns, [][]any{}))
mock.ExpectQuery(`SELECT DISTINCT string_value, number_value FROM .*tag_key = \? AND tag_data_type <> \? LIMIT`).
WithArgs("log.level", "bool", 3).
WillReturnRows(cmock.NewRows(valueColumns, [][]any{{"warn", float64(0)}, {"info", float64(0)}}))
mock.ExpectQuery(`SELECT 1 FROM .*tag_key = \? AND tag_data_type = \? AND tag_type = \?`).
WithArgs("level", "bool", "logfield", 1).
WillReturnRows(cmock.NewRows(boolColumns, [][]any{}))
mock.ExpectQuery(`SELECT DISTINCT string_value, number_value FROM .*tag_key = \? AND tag_data_type <> \? AND tag_type = \?`).
WithArgs("level", "bool", "logfield", 3).
WillReturnRows(cmock.NewRows(valueColumns, [][]any{{"info", float64(0)}, {"debug", float64(0)}}))
selector := telemetrytypes.NewFieldValueSelectorFromPostableFieldValueParams(telemetrytypes.PostableFieldValueParams{
Name: "log.level",
PostableFieldKeysParams: telemetrytypes.PostableFieldKeysParams{Signal: telemetrytypes.SignalLogs, Limit: 2},
})
values, complete, err := metadata.GetAllValues(context.Background(), valuer.GenerateUUID(), selector)
require.NoError(t, err)
assert.Equal(t, []string{"warn", "info"}, values.StringValues)
assert.False(t, complete)
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestMergeFieldValues(t *testing.T) {
testCases := []struct {
name string
limit int
values []*telemetrytypes.TelemetryFieldValues
expected *telemetrytypes.TelemetryFieldValues
expectedComplete bool
}{
{
name: "BoolValuesOutsideLimit",
limit: 1,
values: []*telemetrytypes.TelemetryFieldValues{{BoolValues: []bool{true, false}}, {StringValues: []string{"a"}}},
expected: &telemetrytypes.TelemetryFieldValues{StringValues: []string{"a"}, BoolValues: []bool{true, false}},
expectedComplete: true,
},
{
name: "DuplicateAtLimitKeepsComplete",
limit: 1,
values: []*telemetrytypes.TelemetryFieldValues{{StringValues: []string{"prod"}}, {StringValues: []string{"prod"}}},
expected: &telemetrytypes.TelemetryFieldValues{StringValues: []string{"prod"}},
expectedComplete: true,
},
{
name: "DroppedValueMarksIncomplete",
limit: 1,
values: []*telemetrytypes.TelemetryFieldValues{{StringValues: []string{"prod"}}, {StringValues: []string{"dev"}}},
expected: &telemetrytypes.TelemetryFieldValues{StringValues: []string{"prod"}},
expectedComplete: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
merged, complete := mergeFieldValues(testCase.limit, testCase.values...)
assert.Equal(t, testCase.expected, merged)
assert.Equal(t, testCase.expectedComplete, complete)
})
}
}

View File

@@ -1400,7 +1400,9 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
selectColumn = fmt.Sprintf("if(notEmpty(%s), %s, %s)", memberColumns[i], memberColumns[i], selectColumn)
}
sb := sqlbuilder.Select("DISTINCT " + selectColumn).From(t.relatedMetadataDBName + "." + t.relatedMetadataTblName)
sb := sqlbuilder.Select("DISTINCT " + sqlbuilder.Escape(selectColumn)).From(t.relatedMetadataDBName + "." + t.relatedMetadataTblName)
// rows without the key read as empty; drop them so they do not count towards the limit
sb.Where(sqlbuilder.Escape(fmt.Sprintf("notEmpty(%s)", selectColumn)))
if len(fieldValueSelector.ExistingQuery) != 0 {
keySelectors := querybuilder.QueryStringToKeysSelectors(fieldValueSelector.ExistingQuery)
@@ -1517,7 +1519,24 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
}
func (t *telemetryMetaStore) GetRelatedValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) ([]string, bool, error) {
return t.getRelatedValues(ctx, orgID, fieldValueSelector)
selectors := fieldValueSelector.NameMatchSelectors()
if len(selectors) == 1 {
return t.getRelatedValues(ctx, orgID, fieldValueSelector)
}
all := make([]*telemetrytypes.TelemetryFieldValues, 0, len(selectors))
complete := true
for _, selector := range selectors {
values, selectorComplete, err := t.getRelatedValues(ctx, orgID, selector)
if err != nil {
return nil, false, err
}
all = append(all, &telemetrytypes.TelemetryFieldValues{RelatedValues: values})
complete = complete && selectorComplete
}
merged, mergeComplete := mergeFieldValues(fieldValueSelector.Limit, all...)
return merged.RelatedValues, complete && mergeComplete, nil
}
func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
@@ -1636,7 +1655,7 @@ func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string
switch fieldValueSelector.FieldDataType {
case telemetrytypes.FieldDataTypeString:
sb.Where(sb.ILike("string_value", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeNumber:
case telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64:
sb.Where(sb.IsNotNull("number_value"))
sb.Where(sb.ILike("toString(number_value)", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeUnspecified:
@@ -1723,7 +1742,7 @@ func (t *telemetryMetaStore) getAuditFieldValues(ctx context.Context, fieldValue
switch fieldValueSelector.FieldDataType {
case telemetrytypes.FieldDataTypeString:
sb.Where(sb.ILike("string_value", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeNumber:
case telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64:
sb.Where(sb.IsNotNull("number_value"))
sb.Where(sb.ILike("toString(number_value)", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeUnspecified:
@@ -1826,7 +1845,7 @@ func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, orgID val
}
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
sb.Where(sb.E("attr_type", fieldValueSelector.FieldContext.TagType()))
sb.Where(sb.E("attr_type", fieldValueSelector.FieldContext.MetricAttrType()))
}
if fieldValueSelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
@@ -2092,6 +2111,58 @@ func (t *telemetryMetaStore) getMeterSourceMetricFieldValues(ctx context.Context
return values, complete, nil
}
// mergeFieldValues dedupes the values into one result and reports false when the
// limit dropped any. Bool values stay outside the limit, as in getTagTableValues.
func mergeFieldValues(limit int, values ...*telemetrytypes.TelemetryFieldValues) (*telemetrytypes.TelemetryFieldValues, bool) {
if limit == 0 {
limit = 50
}
merged := &telemetrytypes.TelemetryFieldValues{}
seen := make(map[any]bool)
seenRelated := make(map[any]bool)
seenBool := make(map[bool]bool)
count := 0
complete := true
admit := func(seenValues map[any]bool, value any) bool {
if seenValues[value] {
return false
}
if count >= limit {
complete = false
return false
}
seenValues[value] = true
count++
return true
}
for _, v := range values {
for _, value := range v.StringValues {
if admit(seen, value) {
merged.StringValues = append(merged.StringValues, value)
}
}
for _, value := range v.NumberValues {
if admit(seen, value) {
merged.NumberValues = append(merged.NumberValues, value)
}
}
for _, value := range v.RelatedValues {
if admit(seenRelated, value) {
merged.RelatedValues = append(merged.RelatedValues, value)
}
}
for _, value := range v.BoolValues {
if !seenBool[value] {
seenBool[value] = true
merged.BoolValues = append(merged.BoolValues, value)
}
}
}
return merged, complete
}
func populateAllUnspecifiedValues(allUnspecifiedValues *telemetrytypes.TelemetryFieldValues, mapOfValues map[any]bool, mapOfRelatedValues map[any]bool, values *telemetrytypes.TelemetryFieldValues, limit int) bool {
complete := true
totalCount := len(mapOfValues) + len(mapOfRelatedValues)
@@ -2149,6 +2220,27 @@ func populateAllUnspecifiedValues(allUnspecifiedValues *telemetrytypes.Telemetry
// GetAllValues returns all values and whether the result is complete.
func (t *telemetryMetaStore) GetAllValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
selectors := fieldValueSelector.NameMatchSelectors()
if len(selectors) == 1 {
return t.getAllValues(ctx, orgID, fieldValueSelector)
}
all := make([]*telemetrytypes.TelemetryFieldValues, 0, len(selectors))
complete := true
for _, selector := range selectors {
values, selectorComplete, err := t.getAllValues(ctx, orgID, selector)
if err != nil {
return nil, false, err
}
all = append(all, values)
complete = complete && selectorComplete
}
merged, mergeComplete := mergeFieldValues(fieldValueSelector.Limit, all...)
return merged, complete && mergeComplete, nil
}
func (t *telemetryMetaStore) getAllValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
values := &telemetrytypes.TelemetryFieldValues{}
var complete = true
var err error

View File

@@ -505,7 +505,7 @@ func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetr
var keys []*telemetrytypes.TelemetryFieldKey
switch key.FieldContext {
case telemetrytypes.FieldContextBody:
keys = []*telemetrytypes.TelemetryFieldKey{bodyPath(key.Name, key.FieldDataType, value)}
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)}
case telemetrytypes.FieldContextUnspecified:
if column, ok := logsV2Columns[key.Name]; ok {
keys = []*telemetrytypes.TelemetryFieldKey{{Name: key.Name, FieldContext: telemetrytypes.FieldContextLog, FieldDataType: querybuilder.ColumnDataType(column)}}

View File

@@ -782,7 +782,7 @@ func TestIntrinsicColumnFirst(t *testing.T) {
t.Run("declared scope path selects without a literal spelling", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope}
expr, _, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
expr, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
require.NoError(t, err)
assert.Equal(t, "multiIf(scope.version::String <> '', scope.version::String, NULL)", expr)
})

View File

@@ -228,7 +228,7 @@ func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
fl := familyFlagOn(t)
storage := NewStorage(nil)
expr, _, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
expr, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
require.Equal(t, "multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)", expr)
}

View File

@@ -288,14 +288,14 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
t.Run("group by string", func(t *testing.T) {
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'attribute.user.id'), attributes_string['attribute.user.id'], NULL)", got)
})
t.Run("aggregation numeric", func(t *testing.T) {
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(toFloat64OrNull(attributes.`latency`::String) IS NOT NULL, toFloat64OrNull(attributes.`latency`::String), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
})
@@ -362,7 +362,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toString(toFloat64OrNull(attributes.`http.status_code`::String)), NULL)",
@@ -386,7 +386,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",

View File

@@ -295,7 +295,7 @@ func TestColumnExpressionForTemporalColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage(nil)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})
@@ -326,7 +326,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
require.NoError(t, err)
assert.Equal(t, "timestamp", result)
})
@@ -337,7 +337,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
require.NoError(t, err)
assert.Contains(t, result, "attributes_number['timestamp']")
})
@@ -432,7 +432,7 @@ func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage(nil)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})

View File

@@ -214,8 +214,8 @@ type Resolved struct {
type AggExprRewriter interface {
// Rewrite rewrites the aggregation expression to be used in the query.
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, []string, error)
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, []string, error)
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
}
type Statement struct {

View File

@@ -147,36 +147,8 @@ func (f *TelemetryFieldKey) Equal(key *TelemetryFieldKey) bool {
// key.Normalize()
// // Result: Name: "service.name", FieldContext: FieldContextResource, FieldDataType: FieldDataTypeString
func (f *TelemetryFieldKey) Normalize() {
// Step 1: Parse data type from the right (after the last ":") if not already specified
if f.FieldDataType == FieldDataTypeUnspecified {
if colonIdx := strings.LastIndex(f.Name, ":"); colonIdx != -1 {
potentialDataType := f.Name[colonIdx+1:]
if dt, ok := fieldDataTypes[potentialDataType]; ok && dt != FieldDataTypeUnspecified {
f.FieldDataType = dt
f.Name = f.Name[:colonIdx]
}
}
}
// Step 2: Parse field context from the left if not already specified
if f.FieldContext == FieldContextUnspecified {
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:]
f.FieldContext = fc
// Step 2a: Handle special case for log.body.* fields
if f.FieldContext == FieldContextLog && strings.HasPrefix(f.Name, BodyJSONStringSearchPrefix) {
f.FieldContext = FieldContextBody
f.Name = strings.TrimPrefix(f.Name, BodyJSONStringSearchPrefix)
}
}
}
}
f.normalizeDataType()
f.normalizeContext()
}
// GetFieldKeyFromKeyText returns a TelemetryFieldKey parsed from a key text of the
@@ -279,6 +251,9 @@ type FieldValueSelector struct {
ExistingQuery string `json:"existingQuery" description:"Filter expression already applied in the explorer. Used to compute relatedValues; empty disables them."`
Value string `json:"value" description:"Substring the returned values must contain, case-insensitive except for the meter source, where it is case-sensitive and % and _ act as wildcards. Empty returns any value."`
Limit int `json:"limit" description:"Maximum number of values to return."`
// nameAsWritten is set when FieldContext was parsed from the name's prefix.
nameAsWritten string
}
type GettableFieldKeys struct {
@@ -306,7 +281,7 @@ type GettableFieldValues struct {
type PostableFieldValueParams struct {
PostableFieldKeysParams
Name string `query:"name" description:"The key whose values to return, such as service.name. A context prefix such as resource.service.name sets fieldContext when it is empty."`
Name string `query:"name" description:"The key whose values to return, such as service.name. With fieldContext set, it is the stored name as is. Otherwise a context prefix such as resource.service.name sets the context and a type suffix such as service.name:string sets fieldDataType when that is empty; as in filter expressions, a prefixed name other than resource. also matches a key stored under the full name, whose values come first: log.level returns values of a key named log.level, then of the log field level."`
ExistingQuery string `query:"existingQuery" description:"Filter expression already applied in the explorer. When set, relatedValues lists the values seen on data matching it."`
}
@@ -358,26 +333,36 @@ func NewFieldKeySelectorFromPostableFieldKeysParams(params PostableFieldKeysPara
}
func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValueParams) *FieldValueSelector {
keySelector := NewFieldKeySelectorFromPostableFieldKeysParams(params.PostableFieldKeysParams)
// SearchText filters values here, so it must not be parsed as a key.
keyParams := params.PostableFieldKeysParams
keyParams.SearchText = ""
keySelector := NewFieldKeySelectorFromPostableFieldKeysParams(keyParams)
fieldValueSelector := &FieldValueSelector{
FieldKeySelector: keySelector,
}
fieldValueSelector.Name = params.Name
if params.Name != "" && fieldValueSelector.FieldContext == FieldContextUnspecified {
parsedFieldKey := GetFieldKeyFromKeyText(params.Name)
if parsedFieldKey.FieldContext != FieldContextUnspecified {
// Only apply inferred context if it is valid for the current signal
if isContextValidForSignal(parsedFieldKey.FieldContext, fieldValueSelector.Signal) {
fieldValueSelector.Name = parsedFieldKey.Name
fieldValueSelector.FieldContext = parsedFieldKey.FieldContext
// An explicit context means the name is the stored name as is.
key := TelemetryFieldKey{Name: params.Name, FieldContext: keySelector.FieldContext, FieldDataType: keySelector.FieldDataType}
if keySelector.FieldContext == FieldContextUnspecified {
key.normalizeDataType()
nameWithContext := key.Name
key.normalizeContext()
// Only apply inferred context if it is valid for the current signal
if isContextValidForSignal(key.FieldContext, keySelector.Signal) {
// resource is a keyword prefix, never part of a stored name.
if key.FieldContext != FieldContextUnspecified && key.FieldContext != FieldContextResource {
fieldValueSelector.nameAsWritten = nameWithContext
}
} else {
key.Name = nameWithContext
key.FieldContext = FieldContextUnspecified
}
}
fieldValueSelector.Name = key.Name
fieldValueSelector.FieldContext = key.FieldContext
fieldValueSelector.FieldDataType = key.FieldDataType
fieldValueSelector.ExistingQuery = params.ExistingQuery
fieldValueSelector.Value = params.SearchText
@@ -390,6 +375,17 @@ func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValue
return fieldValueSelector
}
// NameMatchSelectors returns the lookups for the requested key, highest priority first.
// A context parsed from the name may instead be part of the stored name (attribute.key
// may be the attribute named attribute.key), so like the query builder both readings are
// looked up: the name as written, in any context, before the name with its prefix stripped.
func (selector *FieldValueSelector) NameMatchSelectors() []*FieldValueSelector {
if selector.nameAsWritten != "" {
return []*FieldValueSelector{selector.withName(selector.nameAsWritten), selector}
}
return []*FieldValueSelector{selector}
}
func NewTelemetryFieldKey(name string, fieldContext FieldContext, fieldDataType FieldDataType) *TelemetryFieldKey {
return &TelemetryFieldKey{
Name: name,
@@ -421,3 +417,49 @@ type TelemetryFieldKeySkipIndex struct {
IndexExpression string `json:"indexExpression"`
Granularity int `json:"granularity"`
}
func (key *TelemetryFieldKey) normalizeDataType() {
// Step 1: Parse data type from the right (after the last ":") if not already specified
if key.FieldDataType == FieldDataTypeUnspecified {
if colonIdx := strings.LastIndex(key.Name, ":"); colonIdx != -1 {
potentialDataType := key.Name[colonIdx+1:]
if dt, ok := fieldDataTypes[potentialDataType]; ok && dt != FieldDataTypeUnspecified {
key.FieldDataType = dt
key.Name = key.Name[:colonIdx]
}
}
}
}
func (key *TelemetryFieldKey) normalizeContext() {
// Step 2: Parse field context from the left if not already specified
if key.FieldContext == FieldContextUnspecified {
if dotIdx := strings.Index(key.Name, "."); dotIdx != -1 {
potentialContext := key.Name[:dotIdx]
if fc, ok := fieldContexts[potentialContext]; ok && fc != FieldContextUnspecified {
key.Name = key.Name[dotIdx+1:]
key.FieldContext = fc
// Step 2a: Handle special case for log.body.* fields
if key.FieldContext == FieldContextLog && strings.HasPrefix(key.Name, BodyJSONStringSearchPrefix) {
key.FieldContext = FieldContextBody
key.Name = strings.TrimPrefix(key.Name, BodyJSONStringSearchPrefix)
}
}
}
}
}
// withName returns a copy looking up name in any context.
func (selector *FieldValueSelector) withName(name string) *FieldValueSelector {
keySelector := *selector.FieldKeySelector
keySelector.Name = name
keySelector.FieldContext = FieldContextUnspecified
renamed := *selector
renamed.FieldKeySelector = &keySelector
renamed.nameAsWritten = ""
return &renamed
}

View File

@@ -164,6 +164,15 @@ func (f FieldContext) TagType() string {
return ""
}
// MetricAttrType returns the attr_type the metrics metadata table stores for the context;
// metric attributes are stored as point.
func (fieldContext FieldContext) MetricAttrType() string {
if fieldContext == FieldContextAttribute {
return "point"
}
return fieldContext.TagType()
}
func isContextValidForSignal(ctx FieldContext, signal Signal) bool {
if ctx == FieldContextResource ||
ctx == FieldContextAttribute ||

View File

@@ -0,0 +1,25 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFieldContextMetricAttrType(t *testing.T) {
testCases := []struct {
name string
context FieldContext
expected string
}{
{name: "Attribute_Point", context: FieldContextAttribute, expected: "point"},
{name: "Resource_Resource", context: FieldContextResource, expected: "resource"},
{name: "Scope_Scope", context: FieldContextScope, expected: "scope"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, testCase.context.MetricAttrType())
})
}
}

View File

@@ -3,6 +3,9 @@ package telemetrytypes
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetFieldKeyFromKeyText(t *testing.T) {
@@ -422,3 +425,168 @@ func TestNewFieldKeySelectorFromPostableFieldKeysParamsMetricNamespace(t *testin
t.Fatalf("expected metric name to remain empty, got %q", selector.MetricContext.MetricName)
}
}
func TestNewFieldValueSelectorFromPostableFieldValueParams(t *testing.T) {
testCases := []struct {
name string
params PostableFieldValueParams
expectedName string
expectedContext FieldContext
expectedDataType FieldDataType
expectedValue string
expectedLookups []TelemetryFieldKey
}{
{
name: "ResourcePrefix_AlwaysContext_SingleLookup",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "resource.service.name"},
expectedName: "service.name",
expectedContext: FieldContextResource,
expectedLookups: []TelemetryFieldKey{{Name: "service.name", FieldContext: FieldContextResource}},
},
{
name: "AttributePrefix_Parsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "attribute.key"},
expectedName: "key",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "attribute.key", FieldContext: FieldContextUnspecified}, {Name: "key", FieldContext: FieldContextAttribute}},
},
{
name: "DoubleAttributePrefix_OnlyFirstStripped",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "attribute.attribute.key"},
expectedName: "attribute.key",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "attribute.attribute.key", FieldContext: FieldContextUnspecified}, {Name: "attribute.key", FieldContext: FieldContextAttribute}},
},
{
name: "TagAlias_ExactMatchAsWritten",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "tag.region"},
expectedName: "region",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "tag.region", FieldContext: FieldContextUnspecified}, {Name: "region", FieldContext: FieldContextAttribute}},
},
{
name: "LogPrefix_Parsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "log.level"},
expectedName: "level",
expectedContext: FieldContextLog,
expectedLookups: []TelemetryFieldKey{{Name: "log.level", FieldContext: FieldContextUnspecified}, {Name: "level", FieldContext: FieldContextLog}},
},
{
name: "SpanPrefix_Parsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalTraces}, Name: "span.kind"},
expectedName: "kind",
expectedContext: FieldContextSpan,
expectedLookups: []TelemetryFieldKey{{Name: "span.kind", FieldContext: FieldContextUnspecified}, {Name: "kind", FieldContext: FieldContextSpan}},
},
{
name: "ContextInvalidForSignal_KeptAsWritten",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "span.kind"},
expectedName: "span.kind",
expectedContext: FieldContextUnspecified,
expectedLookups: []TelemetryFieldKey{{Name: "span.kind", FieldContext: FieldContextUnspecified}},
},
{
name: "ExplicitContext_NameNotParsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldContext: FieldContextAttribute}, Name: "log.level"},
expectedName: "log.level",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "log.level", FieldContext: FieldContextAttribute}},
},
{
name: "ExplicitBodyContext_SingleLookup",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldContext: FieldContextBody}, Name: "key"},
expectedName: "key",
expectedContext: FieldContextBody,
expectedLookups: []TelemetryFieldKey{{Name: "key", FieldContext: FieldContextBody}},
},
{
name: "ExplicitAttributeContext_SingleLookup",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldContext: FieldContextAttribute}, Name: "key"},
expectedName: "key",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "key", FieldContext: FieldContextAttribute}},
},
{
name: "ExplicitAttributeContext_PrefixedStoredName",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldContext: FieldContextAttribute}, Name: "attribute.key"},
expectedName: "attribute.key",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "attribute.key", FieldContext: FieldContextAttribute}},
},
{
name: "ExplicitContext_TypeSuffixNotParsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldContext: FieldContextAttribute}, Name: "http.method:string"},
expectedName: "http.method:string",
expectedContext: FieldContextAttribute,
expectedLookups: []TelemetryFieldKey{{Name: "http.method:string", FieldContext: FieldContextAttribute}},
},
{
name: "DataTypeSuffix_WithoutContext",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "service.name:string"},
expectedName: "service.name",
expectedContext: FieldContextUnspecified,
expectedDataType: FieldDataTypeString,
expectedLookups: []TelemetryFieldKey{{Name: "service.name", FieldContext: FieldContextUnspecified}},
},
{
name: "DataTypeSuffix_WithContext",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "resource.host.name:string"},
expectedName: "host.name",
expectedContext: FieldContextResource,
expectedDataType: FieldDataTypeString,
expectedLookups: []TelemetryFieldKey{{Name: "host.name", FieldContext: FieldContextResource}},
},
{
name: "DataTypeSuffix_WithInvalidContext",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs}, Name: "span.kind:int64"},
expectedName: "span.kind",
expectedContext: FieldContextUnspecified,
expectedDataType: FieldDataTypeInt64,
expectedLookups: []TelemetryFieldKey{{Name: "span.kind", FieldContext: FieldContextUnspecified}},
},
{
name: "ExplicitDataType_SuffixKept",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, FieldDataType: FieldDataTypeBool}, Name: "http.method:string"},
expectedName: "http.method:string",
expectedContext: FieldContextUnspecified,
expectedDataType: FieldDataTypeBool,
expectedLookups: []TelemetryFieldKey{{Name: "http.method:string", FieldContext: FieldContextUnspecified}},
},
{
name: "SearchTextWithContextPrefix_NotParsedAsKey",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, SearchText: "log.x"}, Name: "service.name"},
expectedName: "service.name",
expectedContext: FieldContextUnspecified,
expectedValue: "log.x",
expectedLookups: []TelemetryFieldKey{{Name: "service.name", FieldContext: FieldContextUnspecified}},
},
{
name: "SearchTextWithContextPrefix_NameStillParsed",
params: PostableFieldValueParams{PostableFieldKeysParams: PostableFieldKeysParams{Signal: SignalLogs, SearchText: "attribute.x"}, Name: "resource.k8s.pod.name"},
expectedName: "k8s.pod.name",
expectedContext: FieldContextResource,
expectedValue: "attribute.x",
expectedLookups: []TelemetryFieldKey{{Name: "k8s.pod.name", FieldContext: FieldContextResource}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selector := NewFieldValueSelectorFromPostableFieldValueParams(testCase.params)
assert.Equal(t, testCase.expectedName, selector.Name)
assert.Equal(t, testCase.expectedContext, selector.FieldContext)
assert.Equal(t, testCase.expectedDataType, selector.FieldDataType)
assert.Equal(t, testCase.expectedValue, selector.Value)
lookups := selector.NameMatchSelectors()
require.Len(t, lookups, len(testCase.expectedLookups))
for i, expected := range testCase.expectedLookups {
assert.Equal(t, expected.Name, lookups[i].Name)
assert.Equal(t, expected.FieldContext, lookups[i].FieldContext)
assert.Equal(t, selector.FieldDataType, lookups[i].FieldDataType)
}
assert.Equal(t, testCase.expectedName, selector.Name, "lookups must not mutate the selector")
})
}
}

View File

@@ -64,17 +64,8 @@ export function antdDriver(page: Page): Antd {
*/
pickByName: async (select, name) => {
const before = await antd.selectedTags(select).count();
// A re-render of the surrounding form (e.g. after a query edit) can close
// the select as the click opens it; re-click only while it is still closed.
await expect(async () => {
const isOpen = await select.evaluate((node) =>
node.classList.contains('ant-select-open'),
);
if (!isOpen) {
await select.click();
}
await expect(select).toHaveClass(/ant-select-open/, { timeout: 2_000 });
}).toPass({ timeout: 15_000 });
await select.click();
await expect(select).toHaveClass(/ant-select-open/);
// `fill` rather than `keyboard.type`: every keystroke re-runs antd's
// filter, so typing a ~30 character name costs seconds per pick, while

View File

@@ -1,261 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import {
expect,
type Locator,
type Page,
type Request,
} from '@playwright/test';
import { watchConsole } from './console';
import { isQueryRange } from './query-range-mock';
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
import { driver, kit } from './kit';
import { EXPLORER, RELATIVE_TIME } from './query-builder/constants';
import {
addOnsAt,
exactly,
metricAt,
openSelect,
} from './query-builder/controls';
import { afterRender, editorAt } from './query-builder/editor';
import type { QueryBuilderDriver, V5Body } from './query-builder/types';
function queryBuilderDriver(page: Page): QueryBuilderDriver {
const bodies: V5Body[] = [];
page.on('request', (request: Request) => {
if (isQueryRange(request.url()) && request.method() === 'POST') {
bodies.push(request.postDataJSON() as V5Body);
}
});
const consoleWatch = watchConsole(page, {
// ANTLR reports every partial expression on console.error, prefixed
// with its position (`line 1:14 ...`).
ignore: ['line 1:', 'line 2:', 'line 3:'],
});
// Waits on a request the action started: a response to one already in
// flight (e.g. after a structural edit) would end the wait too early.
const firedBy = async (action: () => Promise<void>): Promise<V5Body[]> => {
const before = bodies.length;
const answered = page
.waitForRequest((r) => isQueryRange(r.url()) && r.method() === 'POST')
.then((request) => request.response());
await action();
await answered;
return bodies.slice(before);
};
const qb: QueryBuilderDriver = {
async openExplorer(path, { query, raw, view } = {}) {
await dismissQuickFiltersAnnouncement(page);
const params = [`relativeTime=${RELATIVE_TIME}`];
if (view) {
params.push(`panelTypes=${encodeURIComponent(JSON.stringify(view))}`);
}
const composite =
raw ??
(query === undefined
? undefined
: encodeURIComponent(JSON.stringify(query)));
if (composite !== undefined) {
params.push(`compositeQuery=${composite}`);
}
const url = `${path}?${params.join('&')}`;
// The metrics explorer runs nothing until a metric is chosen; its
// settle point is the metric list instead.
if (path === EXPLORER.metrics) {
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v2/metrics')),
page.goto(url),
]);
await expect(qb.query('A').filter.content()).toBeVisible();
return [];
}
const fired = await firedBy(async () => {
await page.goto(url);
});
await expect(qb.query('A').filter.content()).toBeVisible();
return fired;
},
query(name) {
const root = (): Locator => page.getByTestId(`query-${name}`);
// Index-suffixed testids follow screen position, not the query's
// name (A, C, B after a delete and re-add): match them in the block.
const signalSelect = (): Locator =>
root().locator('[data-testid^="query-data-source-selector-"]');
return {
root,
filter: editorAt(page, () =>
root().getByTestId('query-where-clause-editor'),
),
filterStatus: () => root().getByTestId('query-validation-status'),
aggregation: editorAt(page, () =>
root().getByTestId('query-aggregation-editor'),
),
step: () => root().getByTestId('input-Seconds'),
metric: metricAt(page, root),
addOns: addOnsAt(page, root),
actions: () => root().getByTestId('query-actions'),
menuItem: (action) =>
page.getByTestId(`query-actions-item-${action}-query`),
async clone() {
await root().getByTestId('query-actions').click();
await page.getByTestId('query-actions-item-clone-query').click();
await afterRender(page);
},
async delete() {
await root().getByTestId('query-actions').click();
await page.getByTestId('query-actions-item-delete-query').click();
await expect(root()).toHaveCount(0);
await afterRender(page);
},
async toggleVisibility() {
await root().getByTestId('qb-entity-visibility').click();
// The icon is the only visible change and carries no state.
await afterRender(page);
},
collapse: () => root().getByTestId('qb-entity-collapse').click(),
signal: () => signalSelect().locator('.ant-select-selection-item'),
async setSignal(label) {
const options = await openSelect(page, signalSelect());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.signal()).toHaveText(label);
},
};
},
tab: (queryType) => page.getByTestId(`query-type-tab-${queryType}`),
promql: () => page.getByTestId('promql-query-input').first(),
async addQuery(name) {
await page.getByTestId('add-new-query-button').click();
const block = qb.query(name);
await expect(block.filter.content()).toBeVisible();
return block;
},
queryNames: () =>
page.locator('[data-testid^="query-"]').evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('data-testid') ?? '')
.filter((id) => /^query-[A-Z]$/.test(id))
.map((id) => id.slice('query-'.length)),
),
formula(name) {
const root = (): Locator => page.getByTestId(`formula-${name}`);
return {
root,
expression: () => root().getByTestId('formula-expression'),
legend: () => root().getByTestId('formula-legend'),
async delete() {
await root().getByTestId('qb-entity-delete').click();
await expect(root()).toHaveCount(0);
// The block leaves the DOM before the editor's draft follows.
await afterRender(page);
},
};
},
async addFormula(name) {
await page.getByTestId('add-formula-button').click();
const formula = qb.formula(name);
await expect(formula.expression()).toBeVisible();
return formula;
},
traceOperator: {
root: () => page.getByTestId('trace-operator'),
// No testid; the label is followed by a "Beta" tag inside the button.
addButton: () =>
page.getByRole('button').filter({ hasText: /^Add Trace Matching/ }),
async add() {
await qb.traceOperator.addButton().click();
await expect(qb.traceOperator.editor.content()).toBeVisible();
},
async remove() {
// The only direct button child: the trash icon, no testid.
await qb.traceOperator.root().locator(':scope > button').click();
await expect(qb.traceOperator.root()).toHaveCount(0);
},
editor: editorAt(page, () => page.getByTestId('trace-operator-editor')),
},
async addToDashboard(title) {
await page.getByTestId('explorer-add-to-dashboard').click();
const dialog = page.getByTestId('export-panel-dialog');
await dialog
.getByTestId('export-dashboard-select')
.locator('input')
.fill(title);
await dialog.locator(`.ant-select-item-option[title="${title}"]`).click();
await dialog.getByTestId('export-panel-export').click();
await page.waitForURL(/\/panel\/new\?/);
await expect(qb.query('A').filter.content()).toBeVisible();
},
runButton: () => page.locator('[data-run-query-btn]').first(),
// No testid on the container.
logsResults: () => page.locator('.logs-explorer-views'),
async run() {
await afterRender(page);
return firedBy(() => qb.runButton().click());
},
async rerunUntil(read, expected) {
await expect(async () => {
await expect(qb.runButton()).toHaveText(/Run Query/);
await afterRender(page);
await qb.runButton().click();
await expect.poll(read, { timeout: 3_000 }).toEqual(expected);
}).toPass({ timeout: 25_000 });
},
async runShortcut() {
await afterRender(page);
return firedBy(() => page.keyboard.press('ControlOrMeta+Enter'));
},
quickFilter(title, value) {
const checkbox = (): Locator =>
page.getByTestId(`checkbox-${title}-${value}`);
return {
checkbox,
toggle: () => firedBy(() => checkbox().click()),
};
},
spec(fired, name) {
for (const body of fired) {
const match = body.compositeQuery?.queries?.find(
(q) => q.spec?.name === name,
);
if (match) {
return match.spec;
}
}
throw new Error(`no query ${name} in ${fired.length} query_range bodies`);
},
sent: () => bodies,
latest: (name) =>
[...bodies]
.reverse()
.flatMap((body) => body.compositeQuery?.queries ?? [])
.find((query) => query.spec?.name === name)?.spec,
urlQuery() {
const raw = new URL(page.url()).searchParams.get('compositeQuery');
if (!raw) {
return null;
}
// The app encodes before URLSearchParams encodes again.
return JSON.parse(decodeURIComponent(raw)) as Record<string, unknown>;
},
console: consoleWatch,
};
return qb;
}
/** Layers `qb` onto another host's test, e.g. the alert-rule fixtures. */
export const withQueryBuilder = driver('qb', ({ page }) =>
queryBuilderDriver(page),
);
export const test = withQueryBuilder(kit);
export { expect } from './kit';
export * from './query-builder/constants';
export * from './query-builder/types';
export { reduceToLabel } from './query-builder/controls';
export { withMetricTypes } from './query-builder/seed';

View File

@@ -1,17 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
export const EXPLORER = {
logs: '/logs/logs-explorer',
traces: '/traces-explorer',
metrics: '/metrics-explorer/explorer',
} as const;
/** A monotonic Sum in the golden dataset. */
export const SUM_METRIC = 'signoz_calls_total';
/** Another golden Sum, for switching between metrics of one type. */
export const OTHER_SUM_METRIC = 'signoz_latency_count';
/** Inside the golden dataset's window. */
export const RELATIVE_TIME = '6h';

View File

@@ -1,136 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
import { antdDriver } from '../antd';
import { editorAt } from './editor';
import type { AddOnField, AddOns, MetricControls } from './types';
/**
* Opens the antd select inside `select` and returns its options. Scoped to the
* list the select owns: a previous select's list can still be mid-close.
*/
export async function openSelect(
page: Page,
select: Locator,
): Promise<Locator> {
await select.locator('.ant-select-selector').click();
const list = await antdDriver(page).ownDropdown(select);
await expect(list).toBeVisible();
return list.locator('.ant-select-item-option');
}
/** antd renders select options with trailing whitespace ("Sum "). */
export const exactly = (label: string): RegExp =>
new RegExp(`^\\s*${label}\\s*$`);
/** A reduce-to option or value by its operator: "Sum of values in timeframe". */
export const reduceToLabel = (operator: string): RegExp =>
new RegExp(`^\\s*${operator} of values`);
const ADD_ON_CONTENT: Record<AddOnField, string> = {
group_by: 'group-by-content',
having: 'having-content',
order_by: 'order-by-content',
limit: 'limit-content',
legend_format: 'legend-format-content',
reduce_to: 'reduce-to-content',
};
export function addOnsAt(page: Page, root: () => Locator): AddOns {
const content = (field: AddOnField): Locator =>
root().getByTestId(ADD_ON_CONTENT[field]);
const groupBy = (): Locator => root().getByTestId('group-by');
const addOns: AddOns = {
toggles: () => root().locator('[data-testid^="query-add-on-"]'),
async open(field) {
if (await content(field).isVisible()) {
return;
}
await root().getByTestId(`query-add-on-${field}`).click();
await expect(content(field)).toBeVisible();
},
content,
groupBy: {
async add(key) {
const options = await openSelect(page, groupBy());
await groupBy().locator('input').fill(key);
await options
.filter({ hasText: exactly(key) })
.first()
.click();
await page.keyboard.press('Escape');
await expect(
groupBy().locator('.ant-select-selection-item', { hasText: key }),
).toBeVisible();
},
async remove(key) {
await groupBy()
.locator('.ant-select-selection-item', { hasText: key })
.locator('.ant-select-selection-item-remove')
.click();
},
async values() {
const texts = await groupBy()
.locator('.ant-select-selection-item-content')
.allTextContents();
return texts.map((text) => text.trim());
},
},
having: editorAt(page, () => root().getByTestId('having-editor')),
limit: () => content('limit').getByTestId('input-Limit'),
legend: () => content('legend_format').getByTestId('input-Legend format'),
reduceTo: {
value: () =>
root().getByTestId('reduce-to').locator('.ant-select-selection-item'),
// Labels read "<Op> of values in timeframe"; matched on the operator.
async set(label) {
const options = await openSelect(page, root().getByTestId('reduce-to'));
await options.filter({ hasText: reduceToLabel(label) }).click();
await expect(addOns.reduceTo.value()).toHaveText(reduceToLabel(label));
},
},
};
return addOns;
}
export function metricAt(page: Page, root: () => Locator): MetricControls {
const picker = (): Locator =>
root().locator('[data-testid^="metric-name-selector-"]');
const time = (): Locator => root().getByTestId('metrics-time-aggregation');
const space = (): Locator => root().getByTestId('metrics-space-aggregation');
return {
async select(name) {
// An antd AutoComplete: the testid is on a wrapper.
const field = picker();
const options = await openSelect(page, field);
await field.locator('input').fill(name);
// Options carry the metric's type and unit next to its name.
await options.filter({ hasText: name }).first().click();
},
// The selection item, not the root: these selects render their list
// inside the root, so its text includes every option.
input: () => picker().locator('input'),
timeAggregation: () => time().locator('.ant-select-selection-item'),
spaceAggregation: () => space().locator('.ant-select-selection-item'),
async setTimeAggregation(label) {
const options = await openSelect(page, time());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.timeAggregation()).toHaveText(label);
},
async setSpaceAggregation(label) {
const options = await openSelect(page, space());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.spaceAggregation()).toHaveText(label);
},
async spaceOptions() {
const options = await openSelect(page, space());
const labels = await options.allTextContents();
await page.keyboard.press('Escape');
return labels.map((label) => label.trim());
},
};
}

View File

@@ -1,77 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
import type { FilterEditor } from './types';
/** CodeMirror renders its placeholder inside the empty line. */
const EDITOR_DOC = async (root: Locator): Promise<string> => {
const lines = root.locator('.cm-content .cm-line');
const texts = await lines.allTextContents();
const placeholders = await lines.evaluateAll((nodes) =>
nodes.map((node) => node.querySelector('.cm-placeholder')?.textContent ?? ''),
);
return texts
.map((text, i) =>
placeholders[i] ? text.replace(placeholders[i], '') : text,
)
.join('\n');
};
export function editorAt(page: Page, root: () => Locator): FilterEditor {
const content = (): Locator => root().locator('.cm-content');
const editor: FilterEditor = {
root,
content,
async type(text) {
await editor.clear();
await page.keyboard.type(text);
},
async append(text) {
await content().click();
await page.keyboard.press('End');
await page.keyboard.type(text);
},
async clear() {
await content().click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Backspace');
await expect.poll(() => EDITOR_DOC(root())).toBe('');
},
press: (key) => page.keyboard.press(key),
expression: () => EDITOR_DOC(root()),
lineCount: () => root().locator('.cm-line'),
suggestions: () => page.locator('.cm-tooltip-autocomplete'),
async openSuggestions() {
await expect(async () => {
await page.keyboard.press('Control+Space');
await expect(editor.suggestions()).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
},
suggestion: (label) =>
page
.locator('.cm-tooltip-autocomplete [role="option"]')
.filter({ hasText: label }),
};
return editor;
}
/**
* Two frames: an edit reaches React state on its next scheduler task, and an
* action in the same frame reads the previous render. A long render delays
* the frame itself, so this cannot pass before the commit.
*/
export async function afterRender(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
// Runs in the browser; the e2e tsconfig has no DOM lib.
const { requestAnimationFrame: frame } = globalThis as unknown as {
requestAnimationFrame(this: void, callback: () => void): number;
};
frame(() => frame(() => resolve()));
}),
);
}

View File

@@ -1,50 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { postToSeeder } from '../telemetry';
import { seeded } from '../kit';
import type { MetricTypes } from './types';
const HISTOGRAM_BOUNDS = ['1', '2', '4', '+Inf'];
/** Ten minutes of a gauge and a cumulative histogram, names unique per worker. */
export const withMetricTypes = seeded(
'metricTypes',
async ({ page, workerIndex }) => {
const suffix = `${workerIndex}_${Date.now()}`;
const types: MetricTypes = {
gauge: `e2e_qb_gauge_${suffix}`,
histogram: `e2e_qb_histogram_${suffix}`,
};
const now = Date.now();
const points: Record<string, unknown>[] = [];
for (let minute = 0; minute < 10; minute += 1) {
const timestamp = new Date(now - (10 - minute) * 60_000).toISOString();
points.push({
metric_name: types.gauge,
labels: { host: 'qb-host' },
timestamp,
value: 10 + minute,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
});
HISTOGRAM_BOUNDS.forEach((le, bucket) => {
points.push({
metric_name: types.histogram,
labels: { le },
timestamp,
value: (minute + 1) * (bucket + 1),
type_: 'Histogram',
temporality: 'Cumulative',
is_monotonic: false,
});
});
}
await postToSeeder(page.request, '/telemetry/metrics', points);
return types;
},
);

View File

@@ -1,225 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Locator } from '@playwright/test';
import type { ConsoleWatch } from '../console';
/** The golden dataset has only Sums; these are seeded per worker. */
export interface MetricTypes {
gauge: string;
histogram: string;
}
/** One entry of a v5 `query_range` body's `compositeQuery.queries`. */
export interface V5Query {
type: string;
spec: {
name?: string;
signal?: string;
disabled?: boolean;
filter?: { expression?: string };
aggregations?: {
expression?: string;
metricName?: string;
timeAggregation?: string;
spaceAggregation?: string;
reduceTo?: string;
}[];
groupBy?: { name: string }[];
having?: { expression?: string };
order?: { key: { name: string }; direction: string }[];
limit?: number;
stepInterval?: number;
[key: string]: unknown;
};
}
export interface V5Body {
requestType: string;
compositeQuery: { queries: V5Query[] };
}
export interface FilterEditor {
root(): Locator;
/** The contenteditable CodeMirror surface. */
content(): Locator;
/** Replaces the expression by typing it key by key. */
type(text: string): Promise<void>;
/** Appends at the caret without clearing. */
append(text: string): Promise<void>;
clear(): Promise<void>;
press(key: string): Promise<void>;
/** The document text, placeholder excluded, lines joined with `\n`. */
expression(): Promise<string>;
lineCount(): Locator;
/** The open autocomplete popup. */
suggestions(): Locator;
/**
* Ctrl+Space: opens the popup at the caret. Typing opens it only once the
* debounced key fetch answers, which a loaded stack can delay past a test's
* next step.
*/
openSuggestions(): Promise<void>;
suggestion(label: string | RegExp): Locator;
}
export interface QueryBlock {
root(): Locator;
filter: FilterEditor;
/** The filter's status icon: shown only while unfocused and non-empty. */
filterStatus(): Locator;
aggregation: FilterEditor;
/** The aggregation's "every … Seconds" input. */
step(): Locator;
metric: MetricControls;
addOns: AddOns;
/** The "…" menu: offered only where several queries are allowed. */
actions(): Locator;
/** A row of the open "…" menu. */
menuItem(action: 'clone' | 'delete'): Locator;
clone(): Promise<void>;
delete(): Promise<void>;
toggleVisibility(): Promise<void>;
collapse(): Promise<void>;
/** The data-source dropdown's value; only on hosts that let it change. */
signal(): Locator;
setSignal(label: 'Logs' | 'Traces' | 'Metrics'): Promise<void>;
}
export interface FormulaBlock {
root(): Locator;
expression(): Locator;
legend(): Locator;
delete(): Promise<void>;
}
/** `query-add-on-<field>` toggle values: a documented DOM contract. */
export type AddOnField =
| 'group_by'
| 'having'
| 'order_by'
| 'limit'
| 'legend_format'
| 'reduce_to';
export interface AddOns {
/** The toggle row; its children name the add-ons on offer. */
toggles(): Locator;
/** Idempotent: leaves an already-open add-on open. */
open(field: AddOnField): Promise<void>;
content(field: AddOnField): Locator;
groupBy: {
/** Types `key` and picks the suggestion with that exact name. */
add(key: string): Promise<void>;
remove(key: string): Promise<void>;
/** The selected keys, in order. */
values(): Promise<string[]>;
};
having: FilterEditor;
limit(): Locator;
legend(): Locator;
reduceTo: {
/** The selected value. */
value(): Locator;
set(label: string): Promise<void>;
};
}
/** The metrics aggregation section: metric picker plus time/space selects. */
export interface MetricControls {
select(name: string): Promise<void>;
/** The metric name input. */
input(): Locator;
/** The selected value. Absent for histograms. */
timeAggregation(): Locator;
/** The selected value. */
spaceAggregation(): Locator;
setTimeAggregation(label: string): Promise<void>;
setSpaceAggregation(label: string): Promise<void>;
/** Labels offered by the space-aggregation select, as shown. */
spaceOptions(): Promise<string[]>;
}
export interface QuickFilterValue {
checkbox(): Locator;
/** Toggles the value; returns the query_range bodies the toggle fired. */
toggle(): Promise<V5Body[]>;
}
/** The explorer's tab, carried in the `panelTypes` param. */
export type ExplorerView = 'list' | 'graph' | 'table';
export interface OpenOptions {
/** Serialised the way the app writes it. */
query?: unknown;
/** A `compositeQuery` value sent as given, for malformed input. */
raw?: string;
view?: ExplorerView;
}
export interface QueryBuilderDriver {
/**
* Resolves once the page's own first query_range has answered: the explorer
* rewrites its URL and re-mounts the builder until then, which drops
* keystrokes. Returns the bodies that load fired.
*/
openExplorer(path: string, options?: OpenOptions): Promise<V5Body[]>;
query(name: string): QueryBlock;
/** Adds the next query (B, C, …) and waits for its block. */
addQuery(name: string): Promise<QueryBlock>;
/** Builder query names, top to bottom. */
queryNames(): Promise<string[]>;
/** Panel-editor query-type tab, by its queryType (`builder`, `promql`, …). */
tab(queryType: string): Locator;
promql(): Locator;
formula(name: string): FormulaBlock;
addFormula(name: string): Promise<FormulaBlock>;
traceOperator: {
root(): Locator;
/** "Add Trace Matching": offered on traces hosts that allow it. */
addButton(): Locator;
add(): Promise<void>;
remove(): Promise<void>;
editor: FilterEditor;
};
/**
* Explorer "Add to dashboard": picks the dashboard titled `title` and waits
* for the new-panel editor the export navigates to.
*/
addToDashboard(title: string): Promise<void>;
runButton(): Locator;
/** The logs explorer's results area, below the builder. */
logsResults(): Locator;
/** Clicks Run; returns every query_range body the click fired. */
run(): Promise<V5Body[]>;
/**
* Clicks Run until `read()` equals `expected`. For panel-editor structural
* edits, whose draft can lag the DOM: an early Run is a cache hit that
* sends nothing. Never clicks while the button is Cancel.
*/
rerunUntil(read: () => unknown, expected: unknown): Promise<void>;
/**
* Ctrl/Cmd+Enter from the focused field; returns the fired bodies. Waits two
* frames first: editor changes reach React state on its next scheduler task,
* and a keypress inside the same frame as the last keystroke reads the
* previous render — a gap no person can produce.
*/
runShortcut(): Promise<V5Body[]>;
/** A value in an explorer's checkbox quick filter, by its header title. */
quickFilter(title: string, value: string): QuickFilterValue;
/** The spec of query `name` in the first body that has it. */
spec(bodies: V5Body[], name: string): V5Query['spec'];
/** Every query_range body sent since the test started. */
sent(): V5Body[];
/**
* Query `name`'s spec in the newest body that has it, whatever sent it. The
* panel editor fetches on structural edits, so Run after one can be a cache
* hit that sends nothing: poll this rather than waiting on Run.
*/
latest(name: string): V5Query['spec'] | undefined;
/** The URL's `compositeQuery`, parsed. */
urlQuery(): Record<string, unknown> | null;
console: ConsoleWatch;
}

View File

@@ -272,9 +272,7 @@ export async function holdQueryRange(
});
await page.route(QUERY_RANGE_GLOB, async (route) => {
await gate;
// A cancelled run aborts its request while it is held; there is
// nothing left to answer.
await fulfill(route, body).catch(() => undefined);
await fulfill(route, body);
});
return { release: () => release() };
}

View File

@@ -1,154 +0,0 @@
import {
expect,
EXPLORER,
SUM_METRIC,
test,
} from '../../drivers/query-builder';
const HAVING = ['count() > 10', 'count() IN [1, 2]', 'count() NOT IN [1, 2]'];
test.describe('Query builder — add-ons', () => {
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-21 ${explorer} group by keeps order through removal and reload`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer], { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.groupBy.add('deployment.environment');
await addOns.groupBy.add('k8s.namespace.name');
await addOns.groupBy.remove('service.name');
const fired = await qb.run();
const expected = ['deployment.environment', 'k8s.namespace.name'];
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual(
expected,
);
const reloaded = await qb.openExplorer(EXPLORER[explorer], {
view: 'graph',
query: qb.urlQuery(),
});
expect(qb.spec(reloaded, 'A').groupBy?.map((key) => key.name)).toEqual(
expected,
);
expect(await qb.query('A').addOns.groupBy.values()).toEqual(expected);
});
}
test('TC-21 metrics group by offers the metric’s own attributes', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
const query = qb.query('A');
await query.metric.select(SUM_METRIC);
await query.addOns.groupBy.add('operation');
const fired = await qb.run();
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual([
'operation',
]);
});
for (const having of HAVING) {
test(`TC-22 having "${having}" is sent verbatim and accepted`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('having');
await addOns.having.type(having);
await addOns.having.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').having?.expression).toBe(having);
expect(qb.console.failedResponses).toEqual([]);
});
}
test('TC-23 the logs list orders by timestamp then id, traces by timestamp only', async ({
qb,
}) => {
const logs = await qb.openExplorer(EXPLORER.logs, { view: 'list' });
expect(qb.spec(logs, 'A').order?.map((o) => o.key.name)).toEqual([
'timestamp',
'id',
]);
const traces = await qb.openExplorer(EXPLORER.traces, { view: 'list' });
expect(qb.spec(traces, 'A').order?.map((o) => o.key.name)).toEqual([
'timestamp',
]);
});
test('TC-24 a positive limit is sent; empty and zero send none', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('limit');
await addOns.limit().fill('10');
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
await addOns.limit().fill('');
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
await addOns.limit().fill('3');
await qb.run();
await addOns.limit().fill('0');
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
});
test('TC-24 a limit input drops the sign of a negative number', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('limit');
await addOns.limit().fill('10');
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
await addOns.limit().fill('-5');
await expect(addOns.limit()).toHaveValue('5');
expect(qb.spec(await qb.run(), 'A').limit).toBe(5);
expect(qb.console.failedResponses).toEqual([]);
});
test('TC-25 the legend prefills from group by and a custom one is kept', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.groupBy.add('deployment.environment');
await addOns.open('legend_format');
await expect(addOns.legend()).toHaveValue(
'service.name = {{service.name}}, deployment.environment = {{deployment.environment}}',
);
await addOns.legend().fill('{{service.name}} logs');
await addOns.groupBy.add('k8s.namespace.name');
await expect(addOns.legend()).toHaveValue('{{service.name}} logs');
expect(qb.spec(await qb.run(), 'A').legend).toBe('{{service.name}} logs');
});
test('TC-25 the legend opens empty without a group by', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await qb.query('A').addOns.open('legend_format');
await expect(qb.query('A').addOns.legend()).toHaveValue('');
});
});

View File

@@ -1,80 +0,0 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// One comma-separated term per entry in the request's `aggregations`.
const EXPRESSIONS: Record<string, string[]> = {
'a count': ['count()'],
'a distinct count': ['count_distinct(service.name)'],
'a percentile of a span field': ['p99(duration_nano)'],
'two aggregations': ['count()', 'p99(duration_nano)'],
};
test.describe('Query builder — logs and traces aggregations', () => {
for (const [label, terms] of Object.entries(EXPRESSIONS)) {
test(`TC-17 ${label} reaches the request`, async ({ qb }) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
const aggregation = qb.query('A').aggregation;
await aggregation.type(terms.join(', '));
await aggregation.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.map((a) => a.expression)).toEqual(
terms,
);
});
}
test('TC-18 the step interval reaches the request', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
await qb.query('A').step().fill('120');
await page.keyboard.press('Tab');
const fired = await qb.run();
expect(qb.spec(fired, 'A').stepInterval).toBe(120);
});
test('TC-30 a trace operator replaces per-query aggregation until removed', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
await (await qb.addQuery('B')).filter.type("http.status_code = '500'");
await qb.query('B').filter.press('Escape');
await qb.traceOperator.add();
await qb.traceOperator.editor.type('A => B');
await qb.traceOperator.editor.press('Escape');
for (const name of ['A', 'B']) {
await expect(qb.query(name).aggregation.root()).toHaveCount(0);
await expect(qb.query(name).addOns.toggles()).toHaveCount(0);
}
const fired = await qb.run();
const operator = fired
.flatMap((body) => body.compositeQuery.queries)
.find((query) => query.type === 'builder_trace_operator');
expect(operator?.spec).toMatchObject({ expression: 'A => B' });
await qb.traceOperator.remove();
await expect(qb.query('A').aggregation.root()).toBeVisible();
await qb.rerunUntil(
() =>
qb
.sent()
.at(-1)
?.compositeQuery.queries.some(
(query) => query.type === 'builder_trace_operator',
),
false,
);
});
test('TC-30 the logs explorer offers no trace matching', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await expect(qb.query('A').root()).toBeVisible();
await expect(qb.traceOperator.addButton()).toHaveCount(0);
});
});

View File

@@ -1,76 +0,0 @@
import { AlertType, expect, test as alertsTest } from '../../drivers/alerts';
import { withQueryBuilder } from '../../drivers/query-builder';
const test = withQueryBuilder(alertsTest);
const FILTER_A = "http.method = 'GET'";
const FILTER_B = "http.status_code = '500'";
const TRACE_MATCH = 'A => B';
interface SavedRule {
condition?: {
compositeQuery?: {
queries?: {
type: string;
spec: {
name?: string;
expression?: string;
filter?: { expression?: string };
};
}[];
};
};
}
test.describe('Query builder — alerts', () => {
test('TC-06 a trace-matching traces alert saves and reopens intact', async ({
authedPage: page,
alerts,
alertChannel,
ownedRules,
qb,
}) => {
await alerts.form.gotoV2({ alertType: AlertType.TRACES });
await alerts.form.name().fill(`e2e-qb-tc06-${Date.now()}`);
await qb.query('A').filter.type(FILTER_A);
await qb.query('A').filter.press('Escape');
const b = await qb.addQuery('B');
await b.filter.type(FILTER_B);
await b.filter.press('Escape');
await qb.traceOperator.add();
await qb.traceOperator.editor.type(TRACE_MATCH);
await qb.traceOperator.editor.press('Escape');
// Blur first: the editor's blur re-renders the condition section, which
// otherwise closes the channel select as its click opens it.
await qb.traceOperator.editor.content().blur();
await alerts.form.selectThresholdChannel(0, alertChannel.name);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
alerts.form.saveButton().click(),
]);
await ownedRules.register(response);
const saved = response.request().postDataJSON() as SavedRule;
const queries = saved.condition?.compositeQuery?.queries ?? [];
expect(queries).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'builder_trace_operator',
spec: expect.objectContaining({ expression: TRACE_MATCH }),
}),
]),
);
const { data } = (await response.json()) as { data: { id: string } };
await alerts.details.gotoOverview(String(data.id));
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER_A);
await expect.poll(() => qb.query('B').filter.expression()).toBe(FILTER_B);
await expect
.poll(() => qb.traceOperator.editor.expression())
.toBe(TRACE_MATCH);
});
});

View File

@@ -1,72 +0,0 @@
import { emptyDashboard, test as editorTest } from '../../drivers/panel-editor';
import {
expect,
EXPLORER,
withQueryBuilder,
} from '../../drivers/query-builder';
const test = withQueryBuilder(editorTest);
const FILTER = "severity_text = 'ERROR'";
const AGGREGATION = 'count_distinct(service.name)';
interface StoredQuery {
spec: {
filter?: { expression?: string };
aggregations?: { expression?: string }[];
};
}
/** Where a non-List panel keeps its builder queries in the v2 dashboard spec. */
interface StoredPanel {
spec: {
queries: {
spec: { plugin: { kind: string; spec: { queries: StoredQuery[] } } };
}[];
};
}
test.describe('Query builder — explorer to dashboard', () => {
test('TC-05 a logs explorer query survives add to dashboard, save and reopen', async ({
dashboards,
editor,
qb,
}) => {
const dashboard = emptyDashboard();
const title = dashboard.spec.display.name;
const id = await dashboards.seed(dashboard);
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await qb.query('A').filter.type(FILTER);
await qb.query('A').filter.press('Escape');
await qb.query('A').aggregation.type(AGGREGATION);
await qb.query('A').aggregation.press('Escape');
await qb.run();
await qb.addToDashboard(title);
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
await expect
.poll(() => qb.query('A').aggregation.expression())
.toBe(AGGREGATION);
await editor.save();
// The shared spec types model only bare BuilderQuery plugins.
const panels = (await dashboards.get(id)).spec.panels as unknown as Record<
string,
StoredPanel
>;
const [panelId] = Object.keys(panels);
const plugin = panels[panelId].spec.queries[0].spec.plugin;
expect(plugin.kind).toBe('signoz/CompositeQuery');
expect(plugin.spec.queries[0].spec).toMatchObject({
filter: { expression: FILTER },
aggregations: [{ expression: AGGREGATION }],
});
await dashboards.openEditor(id, panelId);
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
await expect
.poll(() => qb.query('A').aggregation.expression())
.toBe(AGGREGATION);
});
});

View File

@@ -1,284 +0,0 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// Typing is real keystrokes: the regressions here (caret jumps, swallowed
// Enter, the run shortcut) only show up through the keyboard path.
const RECENT_KEPT = "service.name = 'frontend' AND severity_text = 'WARN'";
const RECENT_REMOVED = 'logger.name EXISTS';
const COMPOUND: Record<string, string> = {
'NOT over a parenthesised OR':
"NOT (severity_text = 'INFO' OR severity_text = 'WARN') AND service.name = 'frontend'",
'nested groups':
"(service.name = 'frontend' AND (severity_text = 'ERROR' OR severity_text = 'WARN'))",
'NOT IN with EXISTS':
"service.name NOT IN ['frontend', 'adservice'] AND logger.name EXISTS",
};
const queryWithFilter = (expression: string): unknown => ({
queryType: 'builder',
builder: {
queryFormulas: [],
queryData: [
{
queryName: 'A',
dataSource: 'logs',
expression: 'A',
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
});
const VERBATIM: Record<string, string> = {
'an escaped quote': "logger.name = 'it\\'s'",
'a double-quoted string': 'logger.name = "double quoted"',
'a quoted numeric string': "logger.name = '123'",
'a bare number': 'logger.name = 123',
'a boolean': 'logger.name = true',
'spaces and a backslash': "logger.name = 'a b\\\\c'",
};
test.describe('Query builder — filter editor', () => {
test('TC-07 key, operator and value suggestions build the expression', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('severity_te');
await filter.openSuggestions();
await filter.suggestion('severity_text').first().click();
await expect.poll(() => filter.expression()).toMatch(/^severity_text\s*$/);
await filter.openSuggestions();
await expect(filter.suggestion(/^=/).first()).toBeVisible();
await filter.append('= ');
await filter.openSuggestions();
await filter.suggestion('ERROR').first().click();
await expect
.poll(() => filter.expression())
.toMatch(/^severity_text = 'ERROR'\s*$/);
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression?.trim()).toBe(
"severity_text = 'ERROR'",
);
});
for (const [label, expression] of Object.entries(VERBATIM)) {
test(`TC-08 ${label} is sent and restored verbatim`, async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type(expression);
await filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
await page.reload();
await expect(filter.content()).toBeVisible();
await expect.poll(() => filter.expression()).toBe(expression);
await expect(qb.query('A').filterStatus()).toHaveCount(0);
});
}
test('TC-11 the status icon shows only for an unfocused invalid filter', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const query = qb.query('A');
await query.filter.type('service.name =');
await expect(query.filterStatus()).toHaveCount(0);
await query.filter.content().blur();
await expect(query.filterStatus()).toBeVisible();
await query.filter.type("service.name = 'frontend'");
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
await query.filter.clear();
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
});
test('TC-12 Enter and Shift+Enter never insert a newline or run', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type("severity_text = 'ERROR'");
await filter.press('Escape');
await expect(filter.suggestions()).toHaveCount(0);
const sentBefore = qb.sent().length;
await filter.press('Enter');
await filter.press('Shift+Enter');
await expect(filter.lineCount()).toHaveCount(1);
await expect.poll(() => filter.expression()).toBe("severity_text = 'ERROR'");
expect(qb.sent()).toHaveLength(sentBefore);
});
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-13 Cmd/Ctrl+Enter in the ${explorer} filter runs the edited query`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer]);
const filter = qb.query('A').filter;
await filter.type("service.name = 'frontend'");
const fired = await qb.runShortcut();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name = 'frontend'",
);
await expect(filter.lineCount()).toHaveCount(1);
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
});
}
test('TC-14 Escape closes suggestions and keeps the text', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('serv');
await filter.openSuggestions();
await filter.press('Escape');
await expect(filter.suggestions()).toHaveCount(0);
await expect.poll(() => filter.expression()).toBe('serv');
await filter.press('i');
await expect.poll(() => filter.expression()).toBe('servi');
await filter.press('Escape');
await filter.press('Escape');
await expect.poll(() => filter.expression()).toBe('servi');
await expect(filter.content()).toBeFocused();
});
test('TC-09 value suggestions fill an IN list', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('service.name IN ');
await filter.openSuggestions();
await filter.suggestion('frontend').first().click();
await expect
.poll(() => filter.expression())
.toBe("service.name IN ['frontend'] ");
await filter.type("service.name IN ['frontend', 'cartservice']");
await filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name IN ['frontend', 'cartservice']",
);
await expect(qb.query('A').filterStatus()).toHaveCount(0);
});
for (const [label, expression] of Object.entries(COMPOUND)) {
test(`TC-10 ${label} is valid and sent verbatim`, async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const query = qb.query('A');
await query.filter.type(expression);
await query.filter.press('Escape');
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
});
}
test('TC-15 an expression with CRLF from the URL loads without a RangeError', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, {
query: queryWithFilter("body CONTAINS 'line1\r\nline2'"),
});
await expect.poll(() => qb.query('A').filter.expression()).toContain('line1');
expect(qb.console.errors.filter((e) => e.includes('RangeError'))).toEqual([]);
await qb.query('A').filter.append(" AND severity_text = 'ERROR'");
await expect
.poll(() => qb.query('A').filter.expression())
.toMatch(/severity_text = 'ERROR'$/);
expect(qb.console.errors).toEqual([]);
});
test('TC-15 recent searches are offered, applied and deleted', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
for (const expression of [RECENT_KEPT, RECENT_REMOVED]) {
await filter.type(expression);
await filter.press('Escape');
await qb.run();
}
await filter.clear();
await filter.openSuggestions();
await expect(filter.suggestion(RECENT_KEPT)).toBeVisible();
await expect(filter.suggestion(RECENT_REMOVED)).toBeVisible();
await filter.suggestion(RECENT_KEPT).click();
await expect.poll(() => filter.expression()).toBe(RECENT_KEPT);
await filter.clear();
await filter.openSuggestions();
await filter
.suggestion(RECENT_REMOVED)
.getByRole('button', { name: 'Remove from recent searches' })
.click();
await expect(filter.suggestion(RECENT_REMOVED)).toHaveCount(0);
});
test('TC-15 recent searches survive a reload and stay with their signal', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await qb.query('A').filter.type(RECENT_KEPT);
await qb.query('A').filter.press('Escape');
await qb.run();
await qb.openExplorer(EXPLORER.logs);
await qb.query('A').filter.clear();
await qb.query('A').filter.openSuggestions();
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toBeVisible();
await qb.openExplorer(EXPLORER.traces);
await qb.query('A').filter.clear();
await qb.query('A').filter.openSuggestions();
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toHaveCount(0);
});
test('TC-16 a quick-filter value toggles between excluded and cleared', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
const handTyped = "service.name = 'frontend'";
const excluded = `${handTyped} severity_text not in ['ERROR']`;
const error = qb.quickFilter('Severity Text', 'ERROR');
await filter.type(handTyped);
await qb.run();
for (const expected of [excluded, handTyped, excluded]) {
const fired = await error.toggle();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expected);
await expect.poll(() => filter.expression()).toBe(expected);
}
await expect(error.checkbox()).toHaveAttribute('aria-checked', 'false');
});
});

View File

@@ -1,106 +0,0 @@
import {
expect,
EXPLORER,
type MetricTypes,
OTHER_SUM_METRIC,
SUM_METRIC,
test as base,
withMetricTypes,
} from '../../drivers/query-builder';
const test = withMetricTypes(base);
const MOMENT_AGGREGATIONS = ['Sum', 'Avg', 'Min', 'Max'];
interface TypeDefaults {
metric: (types: MetricTypes) => string;
time?: string;
space: string;
spaceOptions: string[];
}
const DEFAULTS: Record<string, TypeDefaults> = {
sum: {
metric: () => SUM_METRIC,
time: 'Rate',
space: 'Sum',
spaceOptions: MOMENT_AGGREGATIONS,
},
gauge: {
metric: (types) => types.gauge,
time: 'Avg',
space: 'Avg',
spaceOptions: MOMENT_AGGREGATIONS,
},
histogram: {
metric: (types) => types.histogram,
space: 'P90',
spaceOptions: ['P50', 'P75', 'P90', 'P95', 'P99'],
},
};
test.describe('Query builder — metric aggregations', () => {
for (const [type, expected] of Object.entries(DEFAULTS)) {
test(`TC-19 a ${type} metric gets its type's default aggregations`, async ({
metricTypes,
qb,
}) => {
const metricName = expected.metric(metricTypes);
await qb.openExplorer(EXPLORER.metrics);
const metric = qb.query('A').metric;
await metric.select(metricName);
await expect(metric.spaceAggregation()).toHaveText(expected.space);
expect(await metric.spaceOptions()).toEqual(expected.spaceOptions);
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
metricName,
spaceAggregation: expected.space.toLowerCase(),
...(expected.time && { timeAggregation: expected.time.toLowerCase() }),
});
});
}
test('TC-19 a histogram has no time aggregation control', async ({
metricTypes,
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
await qb.query('A').metric.select(metricTypes.histogram);
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('P90');
await expect(qb.query('A').metric.timeAggregation()).toHaveCount(0);
});
test('TC-20 aggregations reset only when the metric type changes', async ({
metricTypes,
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
const metric = qb.query('A').metric;
await metric.select(SUM_METRIC);
await metric.setTimeAggregation('Increase');
await metric.setSpaceAggregation('Max');
await metric.select(OTHER_SUM_METRIC);
await expect(metric.timeAggregation()).toHaveText('Increase');
await expect(metric.spaceAggregation()).toHaveText('Max');
await metric.select(metricTypes.gauge);
await expect(metric.timeAggregation()).toHaveText('Avg');
await expect(metric.spaceAggregation()).toHaveText('Avg');
await metric.select(SUM_METRIC);
await expect(metric.timeAggregation()).toHaveText('Rate');
await expect(metric.spaceAggregation()).toHaveText('Sum');
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
metricName: SUM_METRIC,
timeAggregation: 'rate',
spaceAggregation: 'sum',
});
});
});

View File

@@ -1,136 +0,0 @@
import {
logsCountQuery,
metricsQuery,
SINGLE_PANEL_ID,
singlePanelDashboard,
test as editorTest,
} from '../../drivers/panel-editor';
import {
expect,
OTHER_SUM_METRIC,
SUM_METRIC,
withQueryBuilder,
} from '../../drivers/query-builder';
// An added query continues the previous query's signal. On these metrics
// panels it starts with no metric, and Run does nothing until one is picked.
const test = withQueryBuilder(editorTest);
const SIGNAL_LABEL = { logs: 'Logs', traces: 'Traces' } as const;
const CLONED_FILTER = "operation = '/ads/get'";
test.describe('Query builder — multiple queries', () => {
for (const signal of ['logs', 'traces'] as const) {
test(`TC-28 a query added to a ${signal} panel stays ${signal}`, async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery({ signal }) }),
SINGLE_PANEL_ID,
);
const added = await qb.addQuery('B');
await expect(added.signal()).toHaveText(SIGNAL_LABEL[signal]);
await expect(added.aggregation.root()).toBeVisible();
await expect(added.metric.input()).toHaveCount(0);
await added.filter.type("service.name = 'frontend'");
await added.filter.press('Escape');
await qb.rerunUntil(() => qb.latest('B')?.signal, signal);
});
}
test('TC-28 a deleted name is reused by the next added query', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
for (const name of ['B', 'C']) {
await (await qb.addQuery(name)).metric.select(OTHER_SUM_METRIC);
}
expect(await qb.queryNames()).toEqual(['A', 'B', 'C']);
await qb.query('B').delete();
expect(await qb.queryNames()).toEqual(['A', 'C']);
const reAdded = await qb.addQuery('B');
expect(await qb.queryNames()).toEqual(['A', 'C', 'B']);
await reAdded.metric.select(SUM_METRIC);
await expect(reAdded.metric.input()).toHaveValue(SUM_METRIC);
await expect(qb.query('C').metric.input()).toHaveValue(OTHER_SUM_METRIC);
});
test('TC-28 a clone and a hidden query reach the request', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery({ filter: CLONED_FILTER }) }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
await qb.query('A').clone();
await expect
.poll(() => qb.query('C').filter.expression())
.toBe(CLONED_FILTER);
await qb.query('B').toggleVisibility();
await qb.runButton().click();
await expect.poll(() => qb.latest('B')?.disabled).toBe(true);
expect(qb.latest('C')).toMatchObject({
filter: { expression: CLONED_FILTER },
aggregations: [expect.objectContaining({ metricName: SUM_METRIC })],
});
});
test('TC-28 the last remaining query offers no delete', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
await qb.query('B').delete();
await qb.query('A').actions().click();
await expect(qb.query('A').menuItem('clone')).toBeVisible();
await expect(qb.query('A').menuItem('delete')).toHaveCount(0);
});
test('TC-29 a formula over two queries reaches the request', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
const formula = await qb.addFormula('F1');
await formula.expression().fill('A / B');
await formula.legend().fill('ratio');
await qb.runButton().click();
await expect
.poll(() => qb.latest('F1'))
.toMatchObject({ expression: 'A / B', legend: 'ratio' });
await formula.delete();
await qb.rerunUntil(
() =>
qb
.sent()
.at(-1)
?.compositeQuery.queries.map((q) => q.spec.name),
['A', 'B'],
);
});
});

View File

@@ -1,203 +0,0 @@
import {
logsCountQuery,
metricsQuery,
PanelKind,
SINGLE_PANEL_ID,
singlePanelDashboard,
test as editorTest,
} from '../../drivers/panel-editor';
import {
expect,
reduceToLabel,
SUM_METRIC,
withMetricTypes,
withQueryBuilder,
} from '../../drivers/query-builder';
const test = withMetricTypes(withQueryBuilder(editorTest));
const LOGS_FILTER = "severity_text = 'ERROR'";
const TRACES_FILTER = "http.method = 'GET'";
const PROMQL = 'sum(rate(signoz_calls_total[5m]))';
const QUERIES = {
logs: () => logsCountQuery(),
traces: () => logsCountQuery({ signal: 'traces' }),
metrics: () => metricsQuery(),
};
const ALL = ['group_by', 'having', 'order_by', 'limit', 'legend_format'];
const NO_HAVING = ['group_by', 'order_by', 'limit', 'legend_format'];
const METRICS = ['having', 'order_by', 'limit', 'legend_format'];
/**
* Add-on toggles each panel kind offers per signal. Metrics group by lives in
* the aggregation row, so it is never a toggle there.
*/
const ADD_ONS: Record<
keyof typeof QUERIES,
Partial<Record<string, string[]>>
> = {
logs: {
[PanelKind.TimeSeries]: ALL,
[PanelKind.BarChart]: ALL,
[PanelKind.Table]: ALL,
[PanelKind.PieChart]: ALL,
[PanelKind.Number]: ['legend_format'],
[PanelKind.Heatmap]: NO_HAVING,
},
traces: {
[PanelKind.TimeSeries]: ALL,
[PanelKind.BarChart]: ALL,
[PanelKind.Table]: ALL,
[PanelKind.PieChart]: ALL,
[PanelKind.Number]: ['legend_format'],
[PanelKind.Heatmap]: NO_HAVING,
},
metrics: {
[PanelKind.TimeSeries]: METRICS,
[PanelKind.BarChart]: METRICS,
[PanelKind.Table]: [...METRICS, 'reduce_to'],
[PanelKind.PieChart]: [...METRICS, 'reduce_to'],
[PanelKind.Number]: ['legend_format', 'reduce_to'],
[PanelKind.Heatmap]: ['order_by', 'limit', 'legend_format', 'bucket_options'],
},
};
test.describe('Query builder — panel editor', () => {
for (const [signal, kinds] of Object.entries(ADD_ONS)) {
for (const [kind, expected] of Object.entries(kinds)) {
test(`TC-27 ${signal} on ${kind.replace('signoz/', '')} offers its add-ons`, async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: kind as PanelKind,
query: QUERIES[signal as keyof typeof QUERIES](),
}),
SINGLE_PANEL_ID,
);
await expect(qb.query('A').addOns.toggles()).toHaveCount(
expected?.length ?? 0,
);
const toggles = await qb
.query('A')
.addOns.toggles()
.evaluateAll((nodes) =>
nodes.map((node) =>
(node.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
),
);
expect(toggles).toEqual(expected);
});
}
}
test('TC-27 a limit hidden by a kind switch reopens with its value', async ({
dashboards,
editor,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
const addOns = qb.query('A').addOns;
await addOns.open('limit');
await addOns.limit().fill('7');
await editor.switchType(PanelKind.Number);
await expect(addOns.content('limit')).toHaveCount(0);
await editor.switchType(PanelKind.TimeSeries);
await expect(addOns.limit()).toHaveValue('7');
// The kind switch already fetched: Run can be a cache hit.
await qb.runButton().click();
await expect.poll(() => qb.latest('A')?.limit).toBe(7);
});
test('TC-26 reduce-to follows the metric type and keeps a choice within it', async ({
dashboards,
metricTypes,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table, query: metricsQuery() }),
SINGLE_PANEL_ID,
);
const query = qb.query('A');
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
await query.addOns.reduceTo.set('Max');
await query.metric.select('signoz_latency_count');
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Max'));
await query.metric.select(metricTypes.gauge);
await expect(query.addOns.reduceTo.value()).toHaveText(
reduceToLabel('Average'),
);
await query.metric.select(SUM_METRIC);
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
expect(qb.spec(await qb.run(), 'A').aggregations?.[0]).toMatchObject({
metricName: SUM_METRIC,
reduceTo: 'sum',
});
});
test('TC-31 switching signal restores the query left on each signal', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery({ groupBy: [] }) }),
SINGLE_PANEL_ID,
);
const query = qb.query('A');
await query.filter.type(LOGS_FILTER);
await query.filter.press('Escape');
await query.setSignal('Traces');
await expect.poll(() => query.filter.expression()).toBe('');
await query.filter.type(TRACES_FILTER);
await query.filter.press('Escape');
await query.setSignal('Logs');
await expect.poll(() => query.filter.expression()).toBe(LOGS_FILTER);
await query.setSignal('Traces');
await expect.poll(() => query.filter.expression()).toBe(TRACES_FILTER);
await qb.runButton().click();
await expect
.poll(() => qb.latest('A'))
.toMatchObject({
signal: 'traces',
filter: { expression: TRACES_FILTER },
});
});
test('TC-33 a PromQL query is sent as promql and kept across tab switches', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await qb.tab('promql').click();
await qb.promql().fill(PROMQL);
const fired = await qb.run();
expect(fired.at(-1)?.compositeQuery.queries[0]).toMatchObject({
type: 'promql',
spec: { name: 'A', query: PROMQL },
});
await qb.tab('clickhouse_sql').click();
await qb.tab('builder').click();
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('Sum');
await qb.tab('promql').click();
await expect(qb.promql()).toHaveValue(PROMQL);
});
});

View File

@@ -1,80 +0,0 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
import {
holdQueryRange,
mockQueryRange,
mockQueryRangeError,
QueryRange,
unmockQueryRange,
} from '../../drivers/query-range-mock';
test.describe('Query builder — run and cancel', () => {
test('TC-34 a held run can be cancelled and the next run goes through', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const held = await holdQueryRange(page, QueryRange.empty());
await qb.query('A').filter.type("severity_text = 'WARN'");
await qb.query('A').filter.press('Escape');
await qb.runButton().click();
await expect(qb.runButton()).toHaveText('Cancel');
await qb.runButton().click();
await expect(qb.runButton()).toHaveText(/Run Query/);
await expect(qb.logsResults()).toContainText('Query cancelled');
held.release();
await unmockQueryRange(page);
await qb.query('A').filter.type("severity_text = 'ERROR'");
await qb.query('A').filter.press('Escape');
// The cancelled query is re-sent alongside; the newest request wins.
await qb.run();
expect(qb.latest('A')?.filter?.expression).toBe("severity_text = 'ERROR'");
await expect(qb.logsResults()).not.toContainText('Query cancelled');
});
test('TC-35 a failed run keeps the query editable and the next run recovers', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await mockQueryRangeError(page, 'mocked query failure');
await qb.query('A').filter.type("severity_text = 'INFO'");
await qb.query('A').filter.press('Escape');
await qb.runButton().click();
// The failed request is retried before the error settles.
await expect(qb.runButton()).toHaveText(/Run Query/, { timeout: 20_000 });
await expect(qb.logsResults()).toContainText('mocked query failure');
await expect
.poll(() => qb.query('A').filter.expression())
.toBe("severity_text = 'INFO'");
await unmockQueryRange(page);
await qb.query('A').filter.type("severity_text = 'WARN'");
await qb.query('A').filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe("severity_text = 'WARN'");
await expect(qb.logsResults()).not.toContainText('mocked query failure');
});
test('TC-35 an empty result leaves the query unchanged', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await mockQueryRange(page, QueryRange.empty());
await qb.query('A').filter.type("service.name = 'nothing-here'");
await qb.query('A').filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name = 'nothing-here'",
);
await expect
.poll(() => qb.query('A').filter.expression())
.toBe("service.name = 'nothing-here'");
expect(qb.console.errors).toEqual([]);
});
});

View File

@@ -1,194 +0,0 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// A shared or hand-edited explorer link must never take the page down: the
// param is parsed, repaired and rewritten before the builder mounts.
const MALFORMED: Record<string, string> = {
'not JSON': 'not-json',
'truncated JSON': '%7B',
'a JSON string': encodeURIComponent('"a string"'),
'a JSON array': encodeURIComponent('[]'),
'JSON null': 'null',
'an empty JSON string': '%22%22',
'double encoded': encodeURIComponent(
encodeURIComponent(JSON.stringify({ queryType: 'builder' })),
),
};
const PARTIAL: Record<string, unknown> = {
'an empty object': {},
'builder with no builder block': { queryType: 'builder' },
'builder with no queryData': { queryType: 'builder', builder: {} },
'empty queryData': {
queryType: 'builder',
builder: { queryData: [], queryFormulas: [] },
},
'a query with no dataSource': {
queryType: 'builder',
builder: { queryData: [{ queryName: 'A' }] },
},
'an unknown queryType': { queryType: 'zzz' },
'non-array promql and clickhouse blocks': {
queryType: 'builder',
promql: {},
clickhouse_sql: 'x',
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
},
};
/**
* Saved before the v5 builder: structured filters, a having array and an
* aggregate operator. The old having filter wrote `columnName` upper-cased
* (`COUNT()`).
*/
const legacyLogsQuery = (having: unknown[]): unknown => ({
queryType: 'builder',
promql: [],
clickhouse_sql: [],
builder: {
queryFormulas: [],
queryData: [
{
queryName: 'A',
dataSource: 'logs',
expression: 'A',
disabled: false,
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '', isColumn: false },
filters: {
op: 'AND',
items: [
{
id: 'f1',
key: {
key: 'severity_text',
dataType: 'string',
type: '',
isColumn: true,
},
op: '=',
value: 'ERROR',
},
],
},
having,
groupBy: [],
orderBy: [],
functions: [],
legend: '',
limit: null,
stepInterval: 60,
reduceTo: 'avg',
},
],
},
});
const FIRST = "service.name = 'frontend'";
const SECOND = "service.name = 'cartservice'";
interface UrlQuery {
builder?: { queryData?: { filter?: { expression?: string } }[] };
}
test.describe('Query builder — URL handling', () => {
for (const [label, raw] of Object.entries(MALFORMED)) {
test(`TC-01 logs explorer survives ${label} and still runs`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { raw });
await expect(qb.query('A').filter.content()).toBeVisible();
await qb.query('A').filter.type("severity_text = 'ERROR'");
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"severity_text = 'ERROR'",
);
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
expect(qb.console.errors).toEqual([]);
});
}
for (const [label, query] of Object.entries(PARTIAL)) {
test(`TC-02 logs explorer repairs ${label}`, async ({ qb }) => {
const loaded = await qb.openExplorer(EXPLORER.logs, { query });
expect(qb.spec(loaded, 'A').signal).toBe('logs');
await expect
.poll(() => qb.urlQuery())
.toMatchObject({
queryType: 'builder',
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
});
expect(qb.console.errors).toEqual([]);
});
}
test('TC-03 legacy filters and aggregate operator convert to v5 expressions', async ({
qb,
}) => {
const loaded = await qb.openExplorer(EXPLORER.logs, {
view: 'graph',
query: legacyLogsQuery([]),
});
const spec = qb.spec(loaded, 'A');
expect(spec.filter?.expression).toMatch(/^severity_text\s*=\s*'ERROR'$/);
expect(spec.aggregations?.[0]?.expression).toBe('count()');
expect(spec).not.toHaveProperty('filters');
await expect
.poll(() => qb.query('A').filter.expression())
.toMatch(/^severity_text\s*=\s*'ERROR'$/);
expect(qb.console.errors).toEqual([]);
});
test('TC-03 a legacy having as the old builder stored it still runs', async ({
qb,
}) => {
const loaded = await qb.openExplorer(EXPLORER.logs, {
view: 'graph',
query: legacyLogsQuery([{ columnName: 'COUNT()', op: '>', value: 5 }]),
});
expect(qb.spec(loaded, 'A').having?.expression).toBe('count() > 5');
expect(qb.console.failedResponses).toEqual([]);
});
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-04 ${explorer} explorer query follows back, forward and reload`, async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer]);
const filter = qb.query('A').filter;
const urlFilter = (): unknown =>
(qb.urlQuery() as UrlQuery | null)?.builder?.queryData?.[0]?.filter
?.expression;
await filter.type(FIRST);
await qb.run();
await filter.type(SECOND);
await qb.run();
await page.goBack();
await expect.poll(urlFilter).toBe(FIRST);
await expect.poll(() => filter.expression()).toBe(FIRST);
await page.goForward();
await expect.poll(urlFilter).toBe(SECOND);
await expect.poll(() => filter.expression()).toBe(SECOND);
await filter.type("service.name = 'unsaved'");
const sentBefore = qb.sent().length;
await page.reload();
await expect.poll(() => filter.expression()).toBe(SECOND);
await expect
.poll(() => qb.sent().slice(sentBefore).length)
.toBeGreaterThan(0);
expect(qb.spec(qb.sent().slice(sentBefore), 'A').filter?.expression).toBe(
SECOND,
);
});
}
});

View File

@@ -240,14 +240,12 @@ def test_json_body_unknown_key_aggregation_over_body(
assert tuple(data[0]) == (6, 4), data[0]
@pytest.mark.parametrize("prefix", ["", "body."], ids=["bare", "body_prefix"])
def test_json_body_unknown_key_body_path_parity(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[JSONPathType]], None],
prefix: str,
) -> None:
"""
Setup:
@@ -256,11 +254,10 @@ def test_json_body_unknown_key_body_path_parity(
(distributed_field_keys); unknown_field is left unregistered.
Tests:
Grouping, counting distinct values, and selecting by the registered body path
(known_field, metadata-driven) and by the unregistered one (unknown_field,
fallback body path) return identical results, for the bare key and the
body.-prefixed key — the body counterpart of the attribute parity in
13_unknown_key_collisions.py.
Grouping/filtering by the registered body path (known_field, metadata-driven) and
by the unregistered one (unknown_field, synthesized body branch) return identical
results — the synthesize fallback reproduces exported-metadata resolution for body
paths, the body counterpart of the attribute parity in 13_unknown_key_collisions.py.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs = []
@@ -282,10 +279,7 @@ def test_json_body_unknown_key_body_path_parity(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
counts: dict[str, dict[str, int]] = {}
distinct: dict[str, int] = {}
selected: dict[str, list[str]] = {}
for key in ("known_field", "unknown_field"):
field = f"{prefix}{key}"
response = make_scalar_query_request(
signoz,
token,
@@ -295,42 +289,13 @@ def test_json_body_unknown_key_body_path_parity(
"A",
"logs",
[build_aggregation("count()")],
group_by=[build_group_by_field(field, field_data_type="", field_context="")],
group_by=[build_group_by_field(key, field_data_type="", field_context="")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
assert response.status_code == HTTPStatus.OK, f"{key}: {response.text}"
counts[key] = {row[0]: row[-1] for row in get_scalar_table_data(response.json())}
response = make_scalar_query_request(
signoz,
token,
now,
[
build_scalar_query(
"A",
"logs",
[build_aggregation(f"count_distinct({field})")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
distinct[key] = get_scalar_table_data(response.json())[0][-1]
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", limit=100, filter_expression=f'service.name = "{SERVICE}"', select_fields=[{"name": field}])],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
selected[key] = sorted(row["data"][key] for row in get_rows(response))
assert counts["known_field"] == counts["unknown_field"], f"registered {counts['known_field']} != synthesized {counts['unknown_field']}"
assert counts["known_field"] == {"alpha": 2, "beta": 1}, counts["known_field"]
assert distinct == {"known_field": 2, "unknown_field": 2}, distinct
assert selected == {"known_field": ["alpha", "alpha", "beta"], "unknown_field": ["alpha", "alpha", "beta"]}, selected

View File

@@ -1,9 +1,6 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
@@ -13,16 +10,10 @@ from fixtures.querier import (
Aggregation,
BuilderQuery,
MetricAggregation,
OrderBy,
RequestType,
TelemetryFieldKey,
get_all_warnings,
make_preview_query_request,
make_query_request,
)
ORG_ID_NOT_FOUND = "key `orgId` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."
def test_resource_default_warning(
signoz: types.SigNoz,
@@ -393,49 +384,3 @@ def test_no_warnings_for_meter_query(
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert "warning" not in response.json()["data"]
@pytest.mark.parametrize(
"request_type,query_args,expected_warnings",
[
pytest.param(RequestType.RAW, {"select_fields": [TelemetryFieldKey(name="orgId")]}, [ORG_ID_NOT_FOUND], id="select_field"),
pytest.param(RequestType.RAW, {"order": [OrderBy(key=TelemetryFieldKey(name="orgId"))]}, [ORG_ID_NOT_FOUND], id="order_by"),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="count_distinct(orgId)")]}, [ORG_ID_NOT_FOUND], id="aggregation_argument"),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="countIf(orgId = 'acme')")]}, [ORG_ID_NOT_FOUND], id="aggregation_predicate"),
pytest.param(
RequestType.SCALAR,
{"aggregations": [Aggregation(expression="count()")], "group_by": [TelemetryFieldKey(name="orgId")], "filter_expression": "orgId = 'acme'"},
[ORG_ID_NOT_FOUND],
id="filter_and_group_by",
),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="count()")], "group_by": [TelemetryFieldKey(name="service.name")]}, [], id="known_key"),
],
)
def test_logs_unknown_key_outside_the_filter_warns(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
request_type: str,
query_args: dict[str, Any],
expected_warnings: list[str],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "java"}, body="checkout failed for orgId=acme")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
queries = [BuilderQuery(name="A", signal="logs", **query_args).to_dict()]
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=request_type, queries=queries)
assert response.status_code == HTTPStatus.OK, response.text
assert get_all_warnings(response.json()) == [{"message": message} for message in expected_warnings], response.text
# the preview reports the same key warnings for the same payload
preview = make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=request_type, queries=queries)
assert preview.status_code == HTTPStatus.OK, preview.text
preview_warnings = preview.json()["data"]["compositeQuery"]["A"]["warnings"]
assert [warning for warning in preview_warnings if "not found in metadata" in warning] == expected_warnings, preview.text

View File

@@ -137,46 +137,6 @@ def test_logs_filter_expression_with_dollar_in_key(
assert {row["data"]["body"] for row in get_rows(response)} == expected_bodies, f"{expression}: {response.text}"
def test_logs_aggregation_with_dollar_in_key(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=3), resources={"service.name": SERVICE}, attributes={"at$0dollar": "alpha-dollar", "at${x}": "a$0b", "n$0um": 5}, body="alpha body"),
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": SERVICE}, attributes={"at$0dollar": "beta-dollar", "at${x}": "it's", "n$0um": 7}, body="beta body"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": SERVICE}, attributes={"plain": "gamma"}, body="gamma body"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=2)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=1)).timestamp() * 1000)
for expression, expected in [
("count_distinct(at$0dollar)", 2),
("count(DISTINCT at$0dollar)", 2),
("sum(n$0um)", 12),
("countIf(at$0dollar EXISTS)", 2),
("count_distinct(at${x})", 2),
("countIf(at${x} = 'a$0b')", 1),
("countIf(at${x} = 'it\\'s')", 1),
("sumIf(n$0um, at$0dollar = 'beta-dollar')", 7),
]:
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_scalar_query("A", "logs", [build_aggregation(expression)], filter_expression=f"service.name = '{SERVICE}'")],
request_type="scalar",
)
assert response.status_code == HTTPStatus.OK, f"{expression}: {response.text}"
assert get_scalar_table_data(response.json()) == [[expected]], f"{expression}: {response.text}"
@pytest.mark.parametrize("key_id", list(RESOURCE_KEYS), ids=list(RESOURCE_KEYS))
def test_logs_group_by_hostile_resource_attribute(
signoz: types.SigNoz,

View File

@@ -128,172 +128,6 @@ def test_logs_aggregate_functions(
assert by_service[service] == pytest.approx(expected), f"{service}: {by_service[service]} != {expected}"
def test_logs_aggregate_if_predicates_use_filter_syntax(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
svc-a: 3 logs, two with a k8s.pod.name and an x-request-id header, two
with "timeout" in the body; svc-b: 1 log without either, with "timeout".
Tests:
countIf predicates accept the filter-only operators EXISTS, NOT EXISTS,
CONTAINS and NOT CONTAINS, a combination of them, and a key with hyphens;
two predicates in one expression each keep their own values.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# (service, k8s.pod.name, x-request-id header, body)
specs = [
("svc-a", "pod-1", "abc", "request timeout"),
("svc-a", None, None, "request ok"),
("svc-a", "pod-2", "def", "upstream timeout"),
("svc-b", None, None, "timeout"),
]
logs = [
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service} | ({"k8s.pod.name": pod} if pod else {}),
attributes={"http.request.header.x-request-id": request_id} if request_id else {},
body=body,
)
for i, (service, pod, request_id, body) in enumerate(specs)
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="logs",
aggregations=[
build_aggregation("count()", "cnt"),
build_aggregation("countIf(k8s.pod.name EXISTS)", "with_pod"),
build_aggregation("countIf(k8s.pod.name NOT EXISTS)", "without_pod"),
build_aggregation("countIf(body CONTAINS 'timeout')", "timeouts"),
build_aggregation("countIf(body NOT CONTAINS 'timeout')", "not_timeouts"),
build_aggregation("countIf(body CONTAINS 'timeout' AND k8s.pod.name EXISTS)", "pod_timeouts"),
build_aggregation("countIf(http.request.header.x-request-id = 'abc')", "request_abc"),
build_aggregation("countIf(k8s.pod.name = 'pod-1') + countIf(body CONTAINS 'ok')", "pod_1_or_ok"),
],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count()", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
assert [row[0] for row in data] == ["svc-a", "svc-b"]
by_service = {row[0]: row[1:] for row in data}
for service in ["svc-a", "svc-b"]:
rows = [(pod, request_id, body) for svc, pod, request_id, body in specs if svc == service]
expected = [
len(rows),
sum(1 for pod, _, _ in rows if pod),
sum(1 for pod, _, _ in rows if not pod),
sum(1 for _, _, body in rows if "timeout" in body),
sum(1 for _, _, body in rows if "timeout" not in body),
sum(1 for pod, _, body in rows if "timeout" in body and pod),
sum(1 for _, request_id, _ in rows if request_id == "abc"),
sum(1 for pod, _, _ in rows if pod == "pod-1") + sum(1 for _, _, body in rows if "ok" in body),
]
assert by_service[service] == expected, f"{service}: {by_service[service]} != {expected}"
def test_logs_aggregate_arguments_are_field_names(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
svc-a: 3 logs, svc-b: 1 log, each with a body size under a key with a
hyphen and an app label under a key with slashes.
Tests:
Aggregation arguments are read as field names, so keys with hyphens,
slashes and a type suffix resolve, count(*) counts every log, and DISTINCT
keeps its meaning.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# (service, http.response.body-size, k8s.pod.labels.app.kubernetes.io/name)
specs = [
("svc-a", 100, "web"),
("svc-a", 250, "web"),
("svc-a", 400, "api"),
("svc-b", 70, "worker"),
]
logs = [
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service},
attributes={"http.response.body-size": size, "k8s.pod.labels.app.kubernetes.io/name": app},
body=f"{service} log {i}",
)
for i, (service, size, app) in enumerate(specs)
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="logs",
aggregations=[
build_aggregation("count(*)", "cnt"),
build_aggregation("sum(http.response.body-size)", "body_size"),
build_aggregation("max(http.response.body-size:float64)", "max_body_size"),
build_aggregation("count_distinct(k8s.pod.labels.app.kubernetes.io/name)", "apps"),
build_aggregation("count(DISTINCT k8s.pod.labels.app.kubernetes.io/name)", "distinct_apps"),
],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count(*)", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
assert [row[0] for row in data] == ["svc-a", "svc-b"]
by_service = {row[0]: row[1:] for row in data}
for service in ["svc-a", "svc-b"]:
sizes = [size for svc, size, _ in specs if svc == service]
apps = {app for svc, _, app in specs if svc == service}
expected = [len(sizes), sum(sizes), max(sizes), len(apps), len(apps)]
assert by_service[service] == expected, f"{service}: {by_service[service]} != {expected}"
@pytest.mark.parametrize(
"expression",
[
pytest.param("countIf(k8s.pod.name EXISTS", id="unclosed_parenthesis"),
pytest.param("countIf()", id="missing_predicate"),
pytest.param("countIf(latency_ms >= 25, latency_ms < 100)", id="extra_argument"),
pytest.param("sum(1)", id="literal_argument"),
pytest.param("sum(latency_ms * 2)", id="arithmetic_in_argument"),
pytest.param("sum(attributes_number['latency_ms'])", id="column_expression_argument"),
pytest.param("countIf(search('timeout'))", id="search_in_predicate"),
],
)
def test_logs_aggregate_invalid_expression_is_bad_request(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
expression: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation(expression)])
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@pytest.mark.parametrize(
"having_expression,aggregations,expected_services",
[

View File

@@ -106,81 +106,3 @@ def test_list_meter_metric_names(
metrics = data.get("data", {}).get("metrics", [])
metric_names = [m["metricName"] for m in metrics]
assert metric_name in metric_names, f"Expected {metric_name} in metric names, got: {metric_names}"
# Verify /api/v1/fields/values with source=meter filters label values by metricNamespace
# prefix. Inserts meter-source metrics under ns.a and ns.b, then asserts a specific
# prefix returns only matching values while a common prefix returns both.
def test_metric_namespace_meter_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[list[MeterSample]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
samples_a = make_meter_samples(
"meter.ns.a.cost",
{"service": "billing-a"},
now,
count=5,
base_value=10.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
samples_b = make_meter_samples(
"meter.ns.b.cost",
{"service": "billing-b"},
now,
count=5,
base_value=20.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples_a + samples_b)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=meter.ns.a should return only billing-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" not in values
# Common prefix: metricNamespace=meter.ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" in values

View File

@@ -10,8 +10,8 @@ from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCo
# Unknown-key resolution in the scalar shape (group-by -> one value per bucket). Logs/traces
# synthesize an unknown key (attribute maps + body). Observed here: the *filter* path emits a
# key-not-found warning and matches nothing (count 0); the *group-by* path buckets every record
# into a single NULL bucket and emits the same warning.
# key-not-found warning and matches nothing (count 0); the *group-by* path silently buckets
# every record into a single NULL bucket and emits no warning.
UNKNOWN = "totally.unknown.key"
@@ -84,7 +84,7 @@ def test_logs_scalar_group_by_unknown_key_null_bucket(
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""Grouping a scalar count() by an unknown bare key synthesizes a NULL column: all records
collapse into one bucket (200), with the same key-not-found warning as the filter path."""
collapse into one bucket (200), and — unlike the filter path — no key-not-found warning."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "scalar-unknown-gb-logs"
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": service}, body=f"log {i}") for i in range(4)])
@@ -106,7 +106,7 @@ def test_logs_scalar_group_by_unknown_key_null_bucket(
)
assert response.status_code == HTTPStatus.OK, response.text
messages = [w.get("message", "") for w in get_all_warnings(response.json())]
assert any(UNKNOWN in m and "not found" in m for m in messages), messages
assert not any(UNKNOWN in m for m in messages), messages
data = get_scalar_table_data(response.json())
assert len(data) == 1, data
assert data[0][-1] == 4, data
@@ -163,7 +163,7 @@ def test_traces_scalar_group_by_unknown_key_null_bucket(
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Grouping a scalar count() by an unknown bare key synthesizes a NULL column: all spans
collapse into one bucket (200), with the same key-not-found warning as the filter path."""
collapse into one bucket (200), and — unlike the filter path — no key-not-found warning."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "scalar-unknown-gb-traces"
insert_traces(
@@ -198,7 +198,7 @@ def test_traces_scalar_group_by_unknown_key_null_bucket(
)
assert response.status_code == HTTPStatus.OK, response.text
messages = [w.get("message", "") for w in get_all_warnings(response.json())]
assert any(UNKNOWN in m and "not found" in m for m in messages), messages
assert not any(UNKNOWN in m for m in messages), messages
data = get_scalar_table_data(response.json())
assert len(data) == 1, data
assert data[0][-1] == 4, data

View File

@@ -0,0 +1,97 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.metadata import get_field_values
from fixtures.metrics import Metrics
@pytest.mark.parametrize(
"params,expected,complete",
[
pytest.param({"name": "key"}, ["attr-plain"], True, id="unprefixed_name_matches_stored_name"),
pytest.param({"name": "attribute.key"}, ["attr-prefixed", "attr-plain"], True, id="prefixed_name_matches_exact_then_stripped"),
pytest.param({"name": "attribute.key", "limit": 1}, ["attr-prefixed"], False, id="exact_match_wins_the_limit"),
pytest.param({"name": "attribute.attribute.key"}, ["attr-prefixed"], True, id="double_prefix_reaches_prefixed_stored_name"),
pytest.param({"name": "log.severity_text"}, ["attr-severity", "WARN"], True, id="log_prefix_matches_attribute_then_log_field"),
pytest.param({"name": "resource.service.name"}, ["svc-resource"], True, id="resource_prefix_is_always_context"),
pytest.param({"name": "key", "fieldContext": "attribute"}, ["attr-plain"], True, id="explicit_context_single_lookup"),
pytest.param({"name": "attribute.key", "fieldContext": "attribute"}, ["attr-prefixed"], True, id="explicit_context_name_not_parsed"),
pytest.param({"name": "service.name:string"}, ["svc-resource"], True, id="type_suffix_without_context"),
pytest.param({"name": "path", "searchText": "log.arch"}, ["log.archive"], True, id="search_text_not_parsed_as_key"),
pytest.param({"name": "no_such_key"}, [], True, id="unknown_name_returns_no_values"),
pytest.param({"name": "attribute.no_such_key"}, [], True, id="unknown_prefixed_name_returns_no_values"),
],
)
def test_fields_values_name_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
params: dict,
expected: list[str],
complete: bool,
) -> None:
"""A prefixed name returns values of the name as written first, then with the prefix stripped. `resource.` is always a context."""
insert_logs(
[
Logs(
timestamp=datetime.now(tz=UTC),
resources={"service.name": "svc-resource"},
attributes={
"key": "attr-plain",
"attribute.key": "attr-prefixed",
"log.severity_text": "attr-severity",
"resource.service.name": "attr-resource",
"path": "log.archive",
},
severity_text="WARN",
)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"signal": "logs", **params})
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"].get("stringValues", []) == expected
assert response.json()["data"]["complete"] is complete
@pytest.mark.parametrize("name", ["latency", "latency:number", "latency:int64", "latency:float64"])
def test_fields_values_number_search_text(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
name: str,
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now, attributes={"latency": 42}), Logs(timestamp=now, attributes={"latency": 7})])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"signal": "logs", "name": name, "searchText": "4"})
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"].get("numberValues", []) == [42]
def test_fields_values_metric_attribute_context(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""Metric attributes are stored with attr_type point."""
insert_metrics([Metrics(metric_name="fields.requests_total", labels={"service": "svc-metric"}, value=1.0)])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"signal": "metrics", "name": "service", "fieldContext": "attribute"})
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"]["stringValues"] == ["svc-metric"]

View File

@@ -0,0 +1,106 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.metadata import AttributesMetadata, get_field_values
@pytest.mark.parametrize(
"params,expected,complete",
[
pytest.param({"name": "key"}, ["rel-plain"], True, id="unprefixed_name_matches_stored_name"),
pytest.param({"name": "attribute.key"}, ["rel-prefixed", "rel-plain"], True, id="prefixed_name_matches_exact_then_stripped"),
pytest.param({"name": "attribute.key", "limit": 1}, ["rel-prefixed"], False, id="exact_match_wins_the_limit"),
pytest.param({"name": "attribute.key", "searchText": "plain"}, ["rel-plain"], True, id="search_text_filters_both_lookups"),
pytest.param({"name": "attribute.attribute.key"}, ["rel-prefixed"], True, id="double_prefix_reaches_prefixed_stored_name"),
pytest.param({"name": "resource.region"}, ["rel-region"], True, id="resource_prefix_is_always_context"),
pytest.param({"name": "key", "fieldContext": "attribute"}, ["rel-plain"], True, id="explicit_context_single_lookup"),
pytest.param({"name": "attribute.key", "fieldContext": "attribute"}, ["rel-prefixed"], True, id="explicit_context_name_not_parsed"),
],
)
def test_related_values_name_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_attributes_metadata: Callable[[list[AttributesMetadata]], None],
params: dict,
expected: list[str],
complete: bool,
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now, resources={"service.name": "svc-registration"}, body="registers service.name")])
insert_attributes_metadata(
[
AttributesMetadata(data_source="logs", resource_attributes={"service.name": "svc-rel"}, attributes={"key": "rel-plain"}, timestamp=now),
AttributesMetadata(data_source="logs", resource_attributes={"service.name": "svc-rel"}, attributes={"attribute.key": "rel-prefixed"}, timestamp=now),
AttributesMetadata(
data_source="logs",
resource_attributes={"service.name": "svc-rel", "region": "rel-region"},
attributes={"resource.region": "rel-attr-region"},
timestamp=now,
),
AttributesMetadata(
data_source="logs",
resource_attributes={"service.name": "svc-other"},
attributes={"key": "other-plain", "attribute.key": "other-prefixed"},
timestamp=now,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"signal": "logs", "existingQuery": "service.name = 'svc-rel'", **params})
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"].get("relatedValues") == expected
assert response.json()["data"]["complete"] is complete
@pytest.mark.parametrize(
"name,existing_query,expected",
[
pytest.param("no_such_key", "service.name = 'svc-a'", set(), id="unknown_name_returns_no_values"),
pytest.param("key", "service.name = 'svc-a'", {"rel-a"}, id="known_filter_key_filters"),
pytest.param("key", "no_such_filter = 'x'", {"rel-a", "rel-b"}, id="unknown_key_equal_drops_filter"),
pytest.param("key", "service.name = 'svc-a' AND no_such_filter = 'x'", {"rel-a"}, id="unknown_key_equal_dropped_from_and"),
pytest.param("key", "service.name = 'svc-a' OR no_such_filter = 'x'", {"rel-a", "rel-b"}, id="unknown_key_equal_drops_whole_or"),
pytest.param("key", "no_such_filter != 'x'", {"rel-a", "rel-b"}, id="unknown_key_not_equal_drops_filter"),
pytest.param("key", "service.name = 'svc-a' AND no_such_filter != 'x'", {"rel-a"}, id="unknown_key_not_equal_dropped_from_and"),
pytest.param("key", "service.name = 'svc-a' OR no_such_filter != 'x'", {"rel-a", "rel-b"}, id="unknown_key_not_equal_drops_whole_or"),
pytest.param("key", "NOT no_such_filter = 'x'", {"rel-a", "rel-b"}, id="not_over_unknown_key_drops_filter"),
pytest.param("key", "service.name = 'svc-a' AND NOT no_such_filter = 'x'", {"rel-a"}, id="not_over_unknown_key_dropped_from_and"),
pytest.param("key", "no_such_filter NOT EXISTS", {"rel-a", "rel-b"}, id="unknown_key_not_exists_drops_filter"),
],
)
def test_related_values_unknown_keys(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_attributes_metadata: Callable[[list[AttributesMetadata]], None],
name: str,
existing_query: str,
expected: set[str],
) -> None:
"""A term on an unknown key reads as true whatever its operator: it vanishes from an AND and drops the whole OR."""
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now, resources={"service.name": "svc-registration"}, body="registers service.name")])
insert_attributes_metadata(
[
AttributesMetadata(data_source="logs", resource_attributes={"service.name": "svc-a"}, attributes={"key": "rel-a"}, timestamp=now),
AttributesMetadata(data_source="logs", resource_attributes={"service.name": "svc-b"}, attributes={"key": "rel-b"}, timestamp=now),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"signal": "logs", "name": name, "existingQuery": existing_query})
assert response.status_code == HTTPStatus.OK
assert set(response.json()["data"]["values"].get("relatedValues") or []) == expected
assert response.json()["data"]["complete"] is True

View File

@@ -0,0 +1,58 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.metadata import get_field_keys
@pytest.mark.parametrize(
"params,present,absent",
[
pytest.param({"searchText": "key"}, {"key": "attribute", "attribute.key": "attribute"}, ["severity_text"], id="unprefixed_search_matches_names_containing_it"),
pytest.param({"searchText": "attribute.key"}, {"key": "attribute", "attribute.key": "attribute"}, ["service.name"], id="attribute_prefix_sets_context"),
pytest.param({"searchText": "attribute.attribute"}, {"attribute.key": "attribute"}, ["key"], id="double_prefix_searches_prefixed_stored_name"),
pytest.param({"searchText": "attribute.key", "fieldContext": "attribute"}, {"attribute.key": "attribute"}, ["key"], id="explicit_context_search_not_parsed"),
pytest.param({"searchText": "resource.service"}, {"service.name": "resource"}, ["resource.service.name"], id="resource_prefix_is_always_context"),
pytest.param({"searchText": "log.severity"}, {"severity_text": "log"}, ["log.severity_text"], id="log_prefix_searches_log_context_only"),
pytest.param({"searchText": "span.kind"}, {"span.kind": "attribute"}, ["kind"], id="context_invalid_for_signal_kept_in_search"),
],
)
def test_fields_keys_name_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
params: dict,
present: dict[str, str],
absent: list[str],
) -> None:
"""Unlike values, keys search only the prefix's context."""
insert_logs(
[
Logs(
timestamp=datetime.now(tz=UTC),
resources={"service.name": "svc-resource"},
attributes={
"key": "attr-plain",
"attribute.key": "attr-prefixed",
"log.severity_text": "attr-severity",
"resource.service.name": "attr-resource",
"span.kind": "attr-kind",
},
severity_text="WARN",
)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_keys(signoz, token, {"signal": "logs", **params})
assert response.status_code == HTTPStatus.OK
keys = response.json()["data"]["keys"]
assert {name: [key["fieldContext"] for key in keys.get(name, [])] for name in present} == {name: [context] for name, context in present.items()}
assert [name for name in absent if name in keys] == []

View File

@@ -15,13 +15,13 @@ from fixtures.traces import Traces
# Related values (/api/v1/fields/values with existingQuery) are served from the
# shared signoz_metadata.attributes_metadata table, scoped by data_source =
# signal. One dataset (queriercommon/related_values.jsonl) drives every scenario:
# signal. One dataset (quickfilter/related_values.jsonl) drives every scenario:
# - Filter attributes (namespace/statefulset/cluster) are identical across
# data_sources, so the same existingQuery matches under any signal.
# - Target values (k8s.pod.name / host.name) are suffixed per data_source, so
# the returned set differs by signal -> every scenario also proves the
# data_source scoping (no cross-signal leakage).
DATASET = "queriercommon/related_values.jsonl"
DATASET = "quickfilter/related_values.jsonl"
@pytest.fixture(name="seed_related_values", scope="function")

View File

@@ -0,0 +1,87 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.meter import MeterSample, make_meter_samples
# Verify /api/v1/fields/values with source=meter filters label values by metricNamespace
# prefix. Inserts meter-source metrics under ns.a and ns.b, then asserts a specific
# prefix returns only matching values while a common prefix returns both.
def test_metric_namespace_meter_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[list[MeterSample]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
samples_a = make_meter_samples(
"meter.ns.a.cost",
{"service": "billing-a"},
now,
count=5,
base_value=10.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
samples_b = make_meter_samples(
"meter.ns.b.cost",
{"service": "billing-b"},
now,
count=5,
base_value=20.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples_a + samples_b)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=meter.ns.a should return only billing-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" not in values
# Common prefix: metricNamespace=meter.ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" in values