Files
signoz/pkg/querybuilder/resolve.go
Srikanth Chekuri 1643df620b
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
feat: resolve semconv families across logs and metrics (#12870)
#### Description

Phase 2 of #6143: semantic-convention families resolve on logs and
metrics, behind the `resolve_semconv_families` flag (default off), on
the storage contract of #12802.

- Registry: each member carries the scope of its own rename edges, so a
fan-out keeps one membership per target and an ambiguous name stays
literal.
- Logs and metrics families need no family code of their own. The gate
applies to every signal, and `LogicalRead` merges members through each
storage's `Read`.
- Metric-name families union the storage names in every `metric_name`
filter, and the querier reads type, temporality, and the reduced flag
across the family.
- Span-metrics labels: the metrics the processor emits, listed by name,
also read each family member with the `resource_` prefix. Requested
names are never rewritten.
- Values suggestions and related values cover every spelling of the
family.
- `deployment.environment.name` resolves on all three signals.
`db.system.name` stays off until a value-mapping reader exists.

#### Additional Information

- `pkg/semconv.Family` fields are now unexported, and `transition.go` is
removed. #12446 reads the old API and needs an update when stacked.
- A target that emits both names of a metric-name family double-counts
in `sum()` during the overlap window. Reading both names is the feature.
Pinned by a test.
- A family of metrics labels keeps the keyless contract of a single
label: no guard, no NULL group.
2026-10-01 20:28:15 +00:00

218 lines
8.1 KiB
Go

package querybuilder
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
// NewQueryInfo binds the context of one query and evaluates the query-path
// flags one time. A nil flagger keeps resolution literal and the log body in
// its legacy column.
func NewQueryInfo(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, signal telemetrytypes.Signal, metric *telemetrytypes.MetricContext, startNs, endNs uint64) qbtypes.QueryInfo {
q := qbtypes.QueryInfo{
StartNs: startNs,
EndNs: endNs,
Signal: signal,
Metric: metric,
FamiliesOn: SemconvFamiliesEnabled(ctx, orgID, fl),
}
if fl != nil {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
q.BodyJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx)
q.TraceAttrsJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseTraceAttributesJSON, evalCtx)
}
return q
}
// Resolve turns one requested key into its meanings, one time for each
// use. The order is the same for every storage, in the filter and in every
// select field, group by, order by, and aggregation:
//
// 1. matches: the metadata keys under the key's spellings, grouped into
// families when the flag is on. A key under one of the storage's own
// contexts matches its own context first, then a column the storage
// knows under that context, and only then as if it had no context.
// 2. ambiguity: in a filter, several interpretations settle by the
// resource-over-attribute policy, with a warning. A select field, group
// by, order by, or aggregation keeps every interpretation in metadata
// order and folds them.
// 3. intrinsic column first: for a bare key, a column every row has leads.
// Metadata can report the column, or the storage's own tables can. When
// only the storage knows the column, same-named metadata keys of a
// contradicting type drop.
// 4. fallback: with no match, the storage's fallback keys. The not-found
// warning fires only when every one of them is a guess.
func Resolve(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (qbtypes.Resolved, error) {
traits := storage.Traits()
lookup := key
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, key, fieldKeys)
if len(matches) == 0 && slices.Contains(traits.OwnContexts, key.FieldContext) {
// a column the storage knows under the key's own context is the key
// as written, and only a miss corrects to the bare spelling
if fallback, err := storage.Fallback(ctx, q, key, operator, value); err == nil {
if column := alwaysPresent(ctx, q, storage, fallback); column != nil {
return qbtypes.Resolved{Key: key, Fields: []*telemetrytypes.LogicalField{column}, FromFallback: true}, nil
}
}
lookup = telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextUnspecified, key.FieldDataType)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, lookup, fieldKeys)
}
resolved := qbtypes.Resolved{Key: key, Ambiguous: len(matches) > 1}
fields := matches
if operator != qbtypes.FilterOperatorUnknown {
var warning string
fields, warning = ResolveLogicalFields(key, matches)
if warning != "" {
resolved.Warnings = append(resolved.Warnings, warning)
}
}
if lookup.FieldContext == telemetrytypes.FieldContextUnspecified && len(fields) > 0 {
fields = intrinsicColumnFirst(ctx, q, storage, key, operator, value, fields)
}
if len(fields) > 0 {
resolved.Fields = fields
return resolved, nil
}
fallback, err := storage.Fallback(ctx, q, key, operator, value)
if err != nil {
return qbtypes.Resolved{}, err
}
if len(fallback) == 0 {
if traits.UnknownKey == qbtypes.IgnoreUnknownKey {
resolved.Skipped = true
return resolved, nil
}
return qbtypes.Resolved{}, NewKeyNotFoundError(key.Name, maps.Keys(fieldKeys))
}
resolved.FromFallback = true
resolved.Fields = fallback
if fallbackIsGuess(ctx, q, storage, fallback) {
resolved.Warnings = append(resolved.Warnings, NewKeyNotFoundWarning(key.Name))
}
return resolved, nil
}
// ResolveColumn resolves one key for a select field, group by, order by, or
// aggregation, and renders its column expression.
func ResolveColumn(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
target telemetrytypes.FieldDataType,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
resolved, err := Resolve(ctx, q, storage, key, qbtypes.FilterOperatorUnknown, nil, fieldKeys)
if err != nil {
return "", err
}
return Column(ctx, q, storage, resolved, target)
}
// intrinsicColumnFirst puts a column every row has first for a bare key.
// The column comes from the matches when metadata reports it. Otherwise it
// comes from the storage's own fallback. A metadata gap then degrades to
// the correct column, never to a corrupt metadata key. Only in that gap
// does a match of a contradicting data type drop. When metadata reports the
// column too, every match reads.
func intrinsicColumnFirst(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
fields []*telemetrytypes.LogicalField,
) []*telemetrytypes.LogicalField {
column := alwaysPresent(ctx, q, storage, fields)
fromFallback := false
if column == nil {
// a fallback that cannot answer this term has no column for it, and
// its error belongs to the no-match path
if fallback, err := storage.Fallback(ctx, q, key, operator, value); err == nil {
column = alwaysPresent(ctx, q, storage, fallback)
fromFallback = column != nil
}
}
if column == nil {
return fields
}
out := make([]*telemetrytypes.LogicalField, 0, len(fields)+1)
out = append(out, column)
for _, logical := range fields {
if logical == column || sameFact(logical, column) {
continue
}
if fromFallback && !dataTypesConsistent(column.FieldDataType, logical.FieldDataType) {
continue
}
out = append(out, logical)
}
return out
}
// alwaysPresent returns the first field every row has. A field the storage
// cannot test for presence is not that field.
func alwaysPresent(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, fields []*telemetrytypes.LogicalField) *telemetrytypes.LogicalField {
for _, logical := range fields {
if logical.IsFamily() {
continue
}
read, err := storage.Read(ctx, q, logical.Single())
if err == nil && read.WhenAbsent == qbtypes.AlwaysPresent {
return logical
}
}
return nil
}
func sameFact(a, b *telemetrytypes.LogicalField) bool {
return !a.IsFamily() && !b.IsFamily() &&
a.FieldContext == b.FieldContext && a.Single().Name == b.Single().Name
}
// dataTypesConsistent reports whether a metadata key's data type can
// describe the same stored value as the column's. An untyped metadata key
// matches every type. A column without a field data type (a time column)
// matches no type. The numeric kinds match each other.
func dataTypesConsistent(column, entry telemetrytypes.FieldDataType) bool {
if entry == telemetrytypes.FieldDataTypeUnspecified {
return true
}
if column == telemetrytypes.FieldDataTypeUnspecified {
return false
}
if column == entry {
return true
}
return isNumber(column) && isNumber(entry)
}
func isNumber(dt telemetrytypes.FieldDataType) bool {
return dt == telemetrytypes.FieldDataTypeInt64 || dt == telemetrytypes.FieldDataTypeFloat64 || dt == telemetrytypes.FieldDataTypeNumber
}
// fallbackIsGuess reports whether every fallback key is a guess. A column
// the storage knows is not one, and its presence means the key was found.
func fallbackIsGuess(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, fields []*telemetrytypes.LogicalField) bool {
return alwaysPresent(ctx, q, storage, fields) == nil
}