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
19 changed files with 868 additions and 140 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

@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -33,7 +35,18 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: { keepPreviousData: true },
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const rules = useMemo(

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

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

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