Compare commits

...

6 Commits

Author SHA1 Message Date
Tushar Vats
f5dd69ab92 fix(fields): drop empty related values before the limit
Assisted-by: Claude Opus 5.5
2026-11-06 14:05:40 +05:30
Tushar Vats
1821847457 test(quickfilter): cover field keys and values name resolution
Assisted-by: Claude Opus 5.5
2026-11-06 14:05:40 +05:30
Tushar Vats
ddad2a5f93 chore(openapi): regenerate specs
Assisted-by: Claude Opus 5.5
2026-11-05 22:02:12 +05:30
Tushar Vats
c493811ac6 fix(fields): treat resource prefix as a context keyword
Assisted-by: Claude Opus 5.5
2026-11-05 22:02:12 +05:30
Tushar Vats
219e6ac54c chore(openapi): regenerate specs
Assisted-by: Claude Opus 5.5
2026-11-05 00:28:04 +05:30
Tushar Vats
a04f65a4f2 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-05 00:28:03 +05:30
16 changed files with 772 additions and 102 deletions

View File

@@ -15826,13 +15826,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.
@@ -28411,13 +28421,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

@@ -0,0 +1,50 @@
package telemetrymetadata
import (
"context"
"testing"
cmock "github.com/SigNoz/clickhouse-go-mock"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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())
}

View File

@@ -1401,6 +1401,8 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
}
sb := sqlbuilder.Select("DISTINCT " + 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(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) {
@@ -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,23 @@ func (t *telemetryMetaStore) getMeterSourceMetricFieldValues(ctx context.Context
return values, complete, nil
}
// mergeFieldValues dedupes the values into one result capped at limit, and reports
// false when the cap dropped any.
func mergeFieldValues(limit int, values ...*telemetrytypes.TelemetryFieldValues) (*telemetrytypes.TelemetryFieldValues, bool) {
if limit == 0 {
limit = 50
}
merged := &telemetrytypes.TelemetryFieldValues{}
mapOfValues := make(map[any]bool)
mapOfRelatedValues := make(map[any]bool)
complete := true
for _, v := range values {
complete = populateAllUnspecifiedValues(merged, mapOfValues, mapOfRelatedValues, v, limit) && complete
}
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 +2185,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

@@ -147,7 +147,11 @@ func (f *TelemetryFieldKey) Equal(key *TelemetryFieldKey) bool {
// key.Normalize()
// // Result: Name: "service.name", FieldContext: FieldContextResource, FieldDataType: FieldDataTypeString
func (f *TelemetryFieldKey) Normalize() {
f.normalizeDataType()
f.normalizeContext()
}
func (f *TelemetryFieldKey) normalizeDataType() {
// 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 {
@@ -158,7 +162,9 @@ func (f *TelemetryFieldKey) Normalize() {
}
}
}
}
func (f *TelemetryFieldKey) normalizeContext() {
// Step 2: Parse field context from the left if not already specified
if f.FieldContext == FieldContextUnspecified {
if dotIdx := strings.Index(f.Name, "."); dotIdx != -1 {
@@ -279,6 +285,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 +315,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 +367,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 +409,29 @@ 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 (s *FieldValueSelector) NameMatchSelectors() []*FieldValueSelector {
if s.nameAsWritten != "" {
return []*FieldValueSelector{s.withName(s.nameAsWritten), s}
}
return []*FieldValueSelector{s}
}
// withName returns a copy looking up name in any context.
func (s *FieldValueSelector) withName(name string) *FieldValueSelector {
keySelector := *s.FieldKeySelector
keySelector.Name = name
keySelector.FieldContext = FieldContextUnspecified
selector := *s
selector.FieldKeySelector = &keySelector
selector.nameAsWritten = ""
return &selector
}
func NewTelemetryFieldKey(name string, fieldContext FieldContext, fieldDataType FieldDataType) *TelemetryFieldKey {
return &TelemetryFieldKey{
Name: name,

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 (f FieldContext) MetricAttrType() string {
if f == FieldContextAttribute {
return "point"
}
return f.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

@@ -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

@@ -0,0 +1,96 @@
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:
"""
Setup:
Insert a log whose attributes include names that start with a context word: attribute.key next
to key, log.severity_text next to the severity_text log field, and resource.service.name next to
the service.name resource.
Tests:
1. A name with a context prefix returns values of the name as written first, then of the name
with the prefix stripped; resource. is always a context.
2. An explicit fieldContext takes the name as is.
3. A :type suffix is stripped, and searchText filters values without being read as a key.
"""
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
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:
"""
Setup:
Insert a metric with a service label, stored with attr_type point.
Tests:
1. fieldContext=attribute returns the label's values.
"""
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,126 @@
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:
"""
Setup:
Insert attributes_metadata rows for logs carrying key and attribute.key on service svc-rel, the
same keys on svc-other, and an attribute named resource.region next to a region resource.
A log registers service.name so existingQuery can resolve it.
Tests:
1. Related values resolve the name like all values: name as written first, then the prefix
stripped; resource. is always a context and an explicit fieldContext takes the name as is.
2. Rows outside existingQuery never surface.
"""
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:
"""
Setup:
Insert attributes_metadata rows for logs with key on svc-a and on svc-b. A log registers
service.name so existingQuery can resolve it.
Tests:
1. An unknown name returns no values and no error.
2. A term on an unknown key is dropped whatever its operator, and NOT does not flip it: it reads
as true, so 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,68 @@
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:
"""
Setup:
Insert a log whose attributes include names that start with a context word: attribute.key next
to key, log.severity_text next to the severity_text log field, and resource.service.name next to
the service.name resource.
Tests:
1. A context prefix in searchText that is valid for the signal sets the context and is searched
without it; an explicit fieldContext searches the text as is.
2. Keys search stays within that context, so prefixed stored names in another context are not listed.
"""
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