Files
signoz/pkg/statementbuilder/tracesstatementbuilder/statement_builder.go
Srikanth Chekuri 9c886be120 chore(querybuilder): compile every signal through one storage contract (#12802)
The semantic convention family as first call citizen revealed that the
current state of the query builder needs a bit refactoring for long term
maintenance.

The `FieldMapper` and `ConditionBuilder` are now one abstraction
`Storage`.

A storage now answers
- what the compiler cannot know i.e one read per field key (the bare
SQL, the membership present or absent, what an absent row reads, and
whether the read keeps its type or filters only).
- the fallback for a key metadata does not report
- its traits
- and one Condition compilation part.

And we introduce a new type to use in the system, `Resolved`

```
// Resolved is what resolution produces for one key: its meanings, and how
// they came to be. It is the only thing the compilers receive. Compile it
// with the operator and value it was resolved with.
type Resolved struct {
	Key    *telemetrytypes.TelemetryFieldKey
	Fields []*telemetrytypes.LogicalField
	// FromFallback: the fields came from the storage's fallback, not from
	// metadata matches.
	FromFallback bool
	// Ambiguous: the matches held several interpretations.
	Ambiguous bool
	// Skipped: the storage contributes nothing for this key.
	Skipped  bool
	Warnings []string
}
```

The prepared SQL has no changes, where it changed, it specifically made
the expression better by removing the redundant part.

- The prepared SQL remains identical with this refactoring
- No changes to integration tests


Assisted-by: Claude Fable 5.1
2026-09-15 11:36:06 +00:00

994 lines
35 KiB
Go

package tracesstatementbuilder
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
var (
ErrUnsupportedAggregation = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported aggregation")
)
type traceQueryStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
storage qbtypes.Storage
resourceFilterResolver *resourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
skipResourceFingerprintEnabled bool
// traceScope, set only on the per-call copy made by BuildTraceScoped, constrains
// queries to spans whose trace_id is in the __trace_scope CTE.
traceScope *qbtypes.Statement
// traceScopeResource is the __resource_filter CTE traceScope's predicate references,
// emitted only when this builder's own resource filter did not already emit it.
traceScopeResource *qbtypes.Statement
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
// NewFactory returns a provider factory for the trace query statement builder. Its
// New internalizes the storage and the AggExprRewriter, and reads
// SkipResourceFingerprint from the config.
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return factory.NewProviderFactory(
factory.MustNewName("traces"),
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
storage := tracestelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage, fl, telemetrytypes.SignalTraces)
return NewTraceQueryStatementBuilder(
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,
cfg.SkipResourceFingerprint.Enabled, cfg.SkipResourceFingerprint.Threshold,
), nil
},
)
}
func NewTraceQueryStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
storage qbtypes.Storage,
aggExprRewriter qbtypes.AggExprRewriter,
telemetryStore telemetrystore.TelemetryStore,
flagger flagger.Flagger,
skipResourceFingerprintEnable bool,
skipResourceFingerprintThreshold uint64,
) *traceQueryStatementBuilder {
tracesSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema")
resourceFilterResolver := resourcefilter.NewResolver[qbtypes.TraceAggregation](
settings,
tracestelemetryschema.DBName,
tracestelemetryschema.TracesResourceV3TableName,
telemetrytypes.SignalTraces,
telemetrytypes.SourceUnspecified,
metadataStore,
nil,
flagger,
telemetryStore,
skipResourceFingerprintThreshold,
)
return &traceQueryStatementBuilder{
logger: tracesSettings.Logger(),
metadataStore: metadataStore,
storage: storage,
resourceFilterResolver: resourceFilterResolver,
aggExprRewriter: aggExprRewriter,
fl: flagger,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
}
}
// BuildTraceScoped is Build constrained to trace_ids selected by traceScope; the
// receiver is copied so the shared builder stays stateless.
func (b *traceQueryStatementBuilder) BuildTraceScoped(
ctx context.Context,
orgID valuer.UUID,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
traceScope, traceScopeResource *qbtypes.Statement,
) (*qbtypes.Statement, error) {
scoped := *b
scoped.traceScope = traceScope
scoped.traceScopeResource = traceScopeResource
return scoped.Build(ctx, orgID, start, end, requestType, query, variables)
}
// attachTraceScope adds the trace-scope condition to sb and returns the CTE fragments
// + args to prepend; resourceEmitted reports whether the query already carries the
// __resource_filter CTE, so the scope's copy is emitted only when it does not.
func (b *traceQueryStatementBuilder) attachTraceScope(sb *sqlbuilder.SelectBuilder, resourceEmitted bool) ([]string, [][]any) {
if b.traceScope == nil {
return nil, nil
}
sb.Where("trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
var frags []string
var args [][]any
if b.traceScopeResource != nil && !resourceEmitted {
frags = append(frags, fmt.Sprintf("__resource_filter AS (%s)", b.traceScopeResource.Query))
args = append(args, b.traceScopeResource.Args)
}
return append(frags, fmt.Sprintf("__trace_scope AS (%s)", b.traceScope.Query)), append(args, b.traceScope.Args)
}
// Build builds a SQL query for traces based on the given parameters.
func (b *traceQueryStatementBuilder) Build(
ctx context.Context,
orgID valuer.UUID,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
start = querybuilder.ToNanoSecs(start)
end = querybuilder.ToNanoSecs(end)
isSelectFieldsEmpty := false
if requestType == qbtypes.RequestTypeRaw {
isSelectFieldsEmpty = len(query.SelectFields) == 0
// we are expanding here to ensure that all the conflicts are taken care in adjustKeys
// i.e if there is a conflict we strip away context of the key in adjustKeys
query = b.expandRawSelectFields(query)
}
// We modify SelectFields above (injecting default fields), and those default
// fields can carry keys that need evolutions, so fetch keys after that.
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, getKeySelectors(query))
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, err
}
for _, action := range adjustTraceKeys(keys, &query, requestType) {
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
// Create SQL builder
q := sqlbuilder.NewSelectBuilder()
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
case qbtypes.RequestTypeTrace:
return b.buildTraceQuery(ctx, orgID, q, query, start, end, keys, variables)
}
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
}
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) []*telemetrytypes.FieldKeySelector {
var keySelectors []*telemetrytypes.FieldKeySelector
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
keySelectors = append(keySelectors, selectors...)
}
if query.Filter != nil && query.Filter.Expression != "" {
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression)
keySelectors = append(keySelectors, whereClauseSelectors...)
}
for idx := range query.GroupBy {
keySelectors = append(keySelectors, keySelectorsForField(query.GroupBy[idx].TelemetryFieldKey)...)
}
for idx := range query.SelectFields {
keySelectors = append(keySelectors, keySelectorsForField(query.SelectFields[idx])...)
}
for idx := range query.Order {
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
}
for idx := range keySelectors {
keySelectors[idx].Signal = telemetrytypes.SignalTraces
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
}
return keySelectors
}
func keySelectorsForField(key telemetrytypes.TelemetryFieldKey) []*telemetrytypes.FieldKeySelector {
selectors := []*telemetrytypes.FieldKeySelector{
{
Name: key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
},
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: key.FieldDataType,
})
}
return selectors
}
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
// definitions to the keys map. We do this during statement building, not at
// metadata fetch time, because:
// 1. Filter expressions that reference deprecated columns must continue to
// resolve — otherwise they fail with "key not found".
// 2. Doing it at metadata fetch time would also surface deprecated keys in
// autocomplete suggestions, which we don't want.
// 3. We prepend (not append) so the intrinsic/calculated entry wins ordering
// in the multi_if SQL expression.
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
for fieldKeyName, fieldKey := range tracestelemetryschema.IntrinsicFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
for fieldKeyName, fieldKey := range tracestelemetryschema.CalculatedFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
}
func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) []string {
mergeDeprecatedTraceKeys(keys)
// Adjust keys for alias expressions in aggregations
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
/*
Check if user is using multiple contexts or data types for same field name
Idea is to use a super set of keys that can satisfy all the usages
For example, lets consider model_id exists in both attributes and resources
And user is trying to use `attribute.model_id` and `model_id`.
In this case, we'll remove the context from `attribute.model_id`
and make it just `model_id` and remove the duplicate entry.
Same goes with data types.
Consider user is using http.status_code:number and http.status_code
In this case, we'll remove the data type from http.status_code:number
and make it just http.status_code and remove the duplicate entry.
*/
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
/*
Now adjust each key to have correct context and data type
Here we try to make intelligent guesses which work for all users (not just majority)
Reason for doing this is to not create an unexpected behavior for users
*/
for idx := range query.SelectFields {
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
}
for idx := range query.GroupBy {
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
}
for idx := range query.Order {
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
return actions
}
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
// for recording actions taken
actions := []string{}
/*
Check if this key is an intrinsic or calculated field
For example: trace_id (intrinsic), response_status_code (calculated).
*/
// Resolve against the context-qualified name first, then the bare name since that can be instrinsic field e.g. scope.name.
var isIntrinsicOrCalculatedField bool
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.FieldContext.StringValue() + "." + key.Name)
}
if !isIntrinsicOrCalculatedField {
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.Name)
}
if isIntrinsicOrCalculatedField {
actions = append(actions, querybuilder.AdjustKey(key, keys, &intrinsicOrCalculatedField)...)
} else {
actions = append(actions, querybuilder.AdjustKey(key, keys, nil)...)
}
return actions
}
// lookupIntrinsicOrCalculatedField returns the intrinsic or calculated field registered under
// name, across the current and deprecated tables.
func lookupIntrinsicOrCalculatedField(name string) (telemetrytypes.TelemetryFieldKey, bool) {
if f, ok := tracestelemetryschema.IntrinsicFields[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.CalculatedFields[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.CalculatedFieldsDeprecated[name]; ok {
return f, true
}
return telemetrytypes.TelemetryFieldKey{}, false
}
// buildListQuery builds a query for list panel type.
func (b *traceQueryStatementBuilder) buildListQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
isSelectFieldsEmpty bool,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
if err != nil {
return nil, err
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, field := range query.SelectFields {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
}
if isSelectFieldsEmpty {
for _, col := range tracestelemetryschema.ContextualSpanColumns {
sb.SelectMore(col)
}
}
// From table
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
// Add order by
for _, orderBy := range query.Order {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
}
// Add limit and offset
if query.Limit > 0 {
sb.Limit(query.Limit)
} else {
sb.Limit(100)
}
if query.Offset > 0 {
sb.Offset(query.Offset)
}
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
return stmt, nil
}
func (b *traceQueryStatementBuilder) buildTraceQuery(
ctx context.Context,
orgID valuer.UUID,
_ *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
endBucket := end / querybuilder.NsToSeconds
distSB := sqlbuilder.NewSelectBuilder()
distSB.Select("trace_id")
distSB.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
var (
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, distSB, query, start, end, variables)
if err != nil {
return nil, err
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, distSB, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
distSQL, distArgs := distSB.BuildWithFlavor(sqlbuilder.ClickHouse)
cteFragments = append(cteFragments, fmt.Sprintf("__toe AS (%s)", distSQL))
cteArgs = append(cteArgs, distArgs)
// Build the inner subquery for root spans
innerSB := sqlbuilder.NewSelectBuilder()
innerSB.Select("trace_id", "duration_nano", sqlbuilder.Escape("resource_string_service$$name as `service.name`"), "name")
innerSB.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
innerSB.Where("parent_span_id = ''")
// this only helps when there is a filter
if query.Filter != nil && query.Filter.Expression != "" {
innerSB.Where("trace_id GLOBAL IN __toe")
}
// Add time filter to inner query
innerSB.Where(
innerSB.GE("timestamp", fmt.Sprintf("%d", start)),
innerSB.L("timestamp", fmt.Sprintf("%d", end)),
innerSB.GE("ts_bucket_start", startBucket),
innerSB.LE("ts_bucket_start", endBucket))
// order by duration and limit 1 per trace
innerSB.OrderBy("duration_nano DESC")
innerSB.SQL("LIMIT 1 BY trace_id")
innerSQL, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
cteFragments = append(cteFragments, fmt.Sprintf("__toe_duration_sorted AS (%s)", innerSQL))
cteArgs = append(cteArgs, innerArgs)
// main query that joins everything
mainSB := sqlbuilder.NewSelectBuilder()
mainSB.Select(
"__toe_duration_sorted.`service.name` AS `service.name`",
"__toe_duration_sorted.name AS `name`",
"count() AS span_count",
"__toe_duration_sorted.duration_nano AS `duration_nano`",
"__toe_duration_sorted.trace_id AS `trace_id`",
)
// Join the distributed table with the inner subquery
mainSB.SQL("FROM __toe")
mainSB.SQL("INNER JOIN __toe_duration_sorted")
mainSB.SQL("ON __toe.trace_id = __toe_duration_sorted.trace_id")
// Group by trace-level fields
mainSB.GroupBy("trace_id", "duration_nano", "name", "`service.name`")
// order by duration only supported for now
mainSB.OrderBy("duration_nano DESC")
// Limit by trace_id to ensure one row per trace
mainSB.SQL("LIMIT 1 BY trace_id")
if query.Limit > 0 {
mainSB.Limit(query.Limit)
} else {
mainSB.Limit(100)
}
if query.Offset > 0 {
mainSB.Offset(query.Offset)
}
mainSQL, mainArgs := mainSB.BuildWithFlavor(sqlbuilder.ClickHouse)
// combine it all together: WITH … SELECT …
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
return stmt, nil
}
func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
if err != nil {
return nil, err
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
sb.SelectMore(fmt.Sprintf(
"toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts",
int64(query.StepInterval.Seconds()),
))
// Keep original column expressions so we can build the tuple
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, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
)
if err != nil {
return nil, err
}
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
var finalSQL string
var finalArgs []any
if query.Limit > 0 && len(query.GroupBy) > 0 {
// build the scalar “top/bottom-N” query in its own builder.
cteSB := sqlbuilder.NewSelectBuilder()
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, keys, variables, true, true)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", cteStmt.Query))
cteArgs = append(cteArgs, cteStmt.Args)
// Constrain the main query to the rows that appear in the CTE.
tuple := fmt.Sprintf("(%s)", strings.Join(fieldNames, ", "))
sb.Where(fmt.Sprintf("%s GLOBAL IN (SELECT %s FROM __limit_cte)", tuple, strings.Join(fieldNames, ", ")))
// Group by all dimensions
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
if len(query.Order) != 0 {
for _, orderBy := range query.Order {
_, ok := aggOrderBy(orderBy, query)
if !ok {
orderCol := clickhousesql.Identifier(orderBy.Key.Name)
if alias, ok := groupByOrderAlias(orderBy.Key.Name, query.GroupBy); ok {
orderCol = alias
}
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(orderCol), orderBy.Direction.StringValue()))
}
}
sb.OrderBy("ts desc")
}
combinedArgs := allAggChArgs
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
// Stitch it all together: WITH … SELECT …
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
} else {
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
if len(query.Order) != 0 {
for _, orderBy := range query.Order {
_, ok := aggOrderBy(orderBy, query)
if !ok {
orderCol := clickhousesql.Identifier(orderBy.Key.Name)
if alias, ok := groupByOrderAlias(orderBy.Key.Name, query.GroupBy); ok {
orderCol = alias
}
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(orderCol), orderBy.Direction.StringValue()))
}
}
sb.OrderBy("ts desc")
}
combinedArgs := allAggChArgs
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
// Stitch it all together: WITH … SELECT …
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
}
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
return stmt, nil
}
// buildScalarQuery builds a query for scalar panel type.
func (b *traceQueryStatementBuilder) buildScalarQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceCTE bool,
skipHaving bool,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
if err != nil {
return nil, err
}
if frag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
// skipResourceCTE means this scalar is embedded as a CTE of a time-series query,
// which has already emitted the __trace_scope fragment — add only the condition.
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 && !skipResourceCTE {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
allAggChArgs := []any{}
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, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
// for scalar queries, the rate would be end-start
rateInterval := (end - start) / querybuilder.NsToSeconds
// Add aggregation
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
rateInterval,
keys,
)
if err != nil {
return nil, err
}
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
}
// From table
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
// Group by dimensions
sb.GroupBy(fieldNames...)
// Add having clause if needed
if query.Having != nil && query.Having.Expression != "" && !skipHaving {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
// Add order by
for _, orderBy := range query.Order {
idx, ok := aggOrderBy(orderBy, query)
if ok {
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
} else {
orderCol := clickhousesql.Identifier(orderBy.Key.Name)
if alias, ok := groupByOrderAlias(orderBy.Key.Name, query.GroupBy); ok {
orderCol = alias
}
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(orderCol), orderBy.Direction.StringValue()))
}
}
// if there is no order by, then use the __result_0 as the order by
if len(query.Order) == 0 {
sb.OrderBy("__result_0 DESC")
}
// Add limit and offset
if query.Limit > 0 {
sb.Limit(query.Limit)
}
combinedArgs := allAggChArgs
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
return stmt, nil
}
// buildFilterCondition builds SQL condition from filter expression.
func (b *traceQueryStatementBuilder) addFilterCondition(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceFilter bool,
) (querybuilder.PreparedWhereClause, error) {
var preparedWhereClause querybuilder.PreparedWhereClause
var err error
if query.Filter != nil && query.Filter.Expression != "" {
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Query: querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end),
Storage: b.storage,
Logger: b.logger,
FieldKeys: keys,
SkipResourceFilter: skipResourceFilter,
Variables: variables,
})
if err != nil {
return preparedWhereClause, err
}
}
if !preparedWhereClause.IsEmpty() {
sb.AddWhereClause(preparedWhereClause.WhereClause)
}
// add time filter
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
endBucket := end / querybuilder.NsToSeconds
sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.L("timestamp", fmt.Sprintf("%d", end)), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket))
return preparedWhereClause, nil
}
func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) {
for i, agg := range q.Aggregations {
if k.Key.Name == agg.Alias ||
k.Key.Name == agg.Expression ||
k.Key.Name == fmt.Sprintf("%d", i) {
return i, true
}
}
return 0, false
}
// groupByColumnAlias returns the positional SQL alias for the i-th group-by dimension;
// the querier (consume.go stripKeyAlias) strips the prefix to recover the field name.
func groupByColumnAlias(i int, name string) string {
return clickhousesql.Identifier(fmt.Sprintf("__GROUP_BY_KEY_%d_%s", i, name))
}
// selectColumnAlias returns the positional SQL alias for the i-th raw select field. Like
// groupByColumnAlias, the prefix is stripped by the querier.
func selectColumnAlias(i int, name string) string {
return clickhousesql.Identifier(fmt.Sprintf("__SELECT_KEY_%d_%s", i, name))
}
// groupByOrderAlias returns the group-by column alias to order by when orderKey names a
// group-by dimension (matching how the SELECT aliases it), else ("", false).
func groupByOrderAlias(orderKey string, groupBy []qbtypes.GroupByKey) (string, bool) {
for i := range groupBy {
if groupBy[i].Name == orderKey {
return groupByColumnAlias(i, groupBy[i].Name), true
}
}
return "", false
}
func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
if b.skipResourceFingerprintEnabled {
decision, err := b.resourceFilterResolver.Resolve(ctx, orgID, query, start, end, variables)
if err != nil {
return "", nil, true, err
}
switch decision {
case qbtypes.ResourceFilterResolveKindNoOp:
return "", nil, true, nil
case qbtypes.ResourceFilterResolveKindFallback:
return "", nil, false, nil
}
}
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables,
)
if err != nil {
return "", nil, true, err
}
if stmt == nil {
return "", nil, true, nil
}
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
}
// expandRawSelectFields populates SelectFields for raw (list view) queries.
// It must be called before adjustKeys so that normalization runs over the full set.
func (b *traceQueryStatementBuilder) expandRawSelectFields(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
if len(query.SelectFields) == 0 {
selectFields := make([]telemetrytypes.TelemetryFieldKey, 0, len(tracestelemetryschema.IntrinsicSpanFields)+len(tracestelemetryschema.CalculatedSpanFields))
selectFields = append(selectFields, tracestelemetryschema.IntrinsicSpanFields...)
selectFields = append(selectFields, tracestelemetryschema.CalculatedSpanFields...)
query.SelectFields = selectFields
return query
}
selectFields := []telemetrytypes.TelemetryFieldKey{
{Name: tracestelemetryschema.SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: tracestelemetryschema.SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: tracestelemetryschema.SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
for _, field := range query.SelectFields {
// TODO(tvats): If a user specifies attribute.timestamp in the select fields, this loop will basically ignore it, as we already added a field by default. This can be fixed once we close https://github.com/SigNoz/engineering-pod/issues/3693
if field.Name == tracestelemetryschema.SpanTimestampColumn || field.Name == tracestelemetryschema.SpanTraceIDColumn || field.Name == tracestelemetryschema.SpanSpanIDColumn {
continue
}
selectFields = append(selectFields, field)
}
query.SelectFields = selectFields
return query
}