From ecf793ea05189af1c79bd87bbdba4cb7b5cba93d Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:21:45 +0100 Subject: [PATCH] Tempo: Fix TraceQL Search escaping regex queries (#106693) * Fix for TraceQL Search incorrectly escaping regex queries * Update test * Keep existing filter values when adding custom --- .../dataquery/x/TempoDataQuery_types.gen.ts | 4 ++ .../kinds/dataquery/types_dataquery_gen.go | 2 + .../SearchTraceQLEditor/SearchField.test.tsx | 65 ++++++++++++++++++- .../tempo/SearchTraceQLEditor/SearchField.tsx | 37 +++++++++-- .../tempo/SearchTraceQLEditor/utils.ts | 3 +- .../plugins/datasource/tempo/dataquery.cue | 2 + .../plugins/datasource/tempo/dataquery.gen.ts | 4 ++ 7 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts index 342f399cd17..9f48e49018d 100644 --- a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts @@ -125,6 +125,10 @@ export interface TraceqlFilter { * Uniquely identify the filter, will not be used in the query generation */ id: string; + /** + * Whether the value is a custom value typed by the user + */ + isCustomValue?: boolean; /** * The operator that connects the tag to the value, for example: =, >, !=, =~ */ diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index d7aad96a985..8edddb0710a 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -84,6 +84,8 @@ type TraceqlFilter struct { ValueType *string `json:"valueType,omitempty"` // The scope of the filter, can either be unscoped/all scopes, resource or span Scope *TraceqlSearchScope `json:"scope,omitempty"` + // Whether the value is a custom value typed by the user + IsCustomValue *bool `json:"isCustomValue,omitempty"` } // NewTraceqlFilter creates a new TraceqlFilter object. diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx index dbd93c9060a..96f41e3f102 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx @@ -74,6 +74,7 @@ describe('SearchField', () => { }); const filter: TraceqlFilter = { id: 'test1', + isCustomValue: false, valueType: 'string', tag: 'test-tag', }; @@ -260,6 +261,66 @@ describe('SearchField', () => { }); } }); + + it('should create custom option with single value when filter value is not an array', async () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { + id: 'test1', + valueType: 'string', + tag: 'test-tag', + value: 'existing-value', + }; + + const { container } = renderSearchField(updateFilter, filter, [], false, undefined, false); + + const select = container.querySelector(`input[aria-label="select test1 value"]`); + expect(select).not.toBeNull(); + expect(select).toBeInTheDocument(); + + if (select) { + await user.type(select, 'custom-value'); + await user.keyboard('{Enter}'); + + expect(updateFilter).toHaveBeenCalledWith({ + ...filter, + value: 'custom-value', + valueType: 'string', + isCustomValue: true, + }); + } + }); + + it('should create custom option with array value when filter value is an array', async () => { + const updateFilter = jest.fn((val) => { + return val; + }); + const filter: TraceqlFilter = { + id: 'test1', + valueType: 'string', + tag: 'test-tag', + value: ['existing-value1', 'existing-value2'], + }; + + const { container } = renderSearchField(updateFilter, filter, [], false, undefined, true); + + const select = container.querySelector(`input[aria-label="select test1 value"]`); + expect(select).not.toBeNull(); + expect(select).toBeInTheDocument(); + + if (select) { + await user.type(select, 'custom-value'); + await user.keyboard('{Enter}'); + + expect(updateFilter).toHaveBeenCalledWith({ + ...filter, + value: ['existing-value1', 'existing-value2', 'custom-value'], + valueType: 'string', + isCustomValue: true, + }); + } + }); }); const renderSearchField = ( @@ -267,7 +328,8 @@ const renderSearchField = ( filter: TraceqlFilter, tags?: string[], hideTag?: boolean, - lp?: LanguageProvider + lp?: LanguageProvider, + isMulti?: boolean ) => { const languageProvider = lp || @@ -313,6 +375,7 @@ const renderSearchField = ( hideTag={hideTag} query={'{}'} addVariablesToOptions={true} + isMulti={isMulti} /> ); }; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx index cd51b4c1764..c7b7bb6832c 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx @@ -127,12 +127,27 @@ const SearchField = ({ return; } + let currentOptions = options; + + // Add custom value if it exists and isn't already in options + if (filter.isCustomValue && filter.value) { + const customValue = Array.isArray(filter.value) ? filter.value : [filter.value]; + + const newCustomOptions = customValue + .filter((val) => !options.some((opt) => opt.value === val)) + .map((val) => ({ label: val, value: val, type: filter.valueType })); + + if (newCustomOptions.length > 0) { + currentOptions = [...options, ...newCustomOptions]; + } + } + if (tagValuesQuery.length === 0) { - return options.slice(0, OPTIONS_LIMIT); + return currentOptions.slice(0, OPTIONS_LIMIT); } const queryLowerCase = tagValuesQuery.toLowerCase(); - return options + return currentOptions .filter((tag) => { if (tag.value && tag.value.length > 0) { return tag.value.toLowerCase().includes(queryLowerCase); @@ -140,7 +155,7 @@ const SearchField = ({ return false; }) .slice(0, OPTIONS_LIMIT); - }, [tagValuesQuery, options]); + }, [tagValuesQuery, options, filter.isCustomValue, filter.value, filter.valueType]); return ( <> @@ -218,11 +233,25 @@ const SearchField = ({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type || uniqueOptionType, + isCustomValue: false, }); } else { - updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType }); + updateFilter({ + ...filter, + value: val?.value, + valueType: val?.type || uniqueOptionType, + isCustomValue: false, + }); } }} + onCreateOption={(val) => { + updateFilter({ + ...filter, + value: Array.isArray(filter.value) ? filter.value?.concat(val) : val, + valueType: uniqueOptionType, + isCustomValue: true, + }); + }} placeholder="Select value" isClearable={true} aria-label={`select ${filter.id} value`} diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts index 1a588b4a717..8f4828073d0 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts @@ -35,7 +35,8 @@ const isRegExpOperator = (operator: string) => operator === '=~' || operator === const escapeValues = (values: string[]) => getEscapedSpanNames(values); export const valueHelper = (f: TraceqlFilter) => { - const value = Array.isArray(f.value) && isRegExpOperator(f.operator!) ? escapeValues(f.value) : f.value; + const value = + Array.isArray(f.value) && isRegExpOperator(f.operator!) && !f.isCustomValue ? escapeValues(f.value) : f.value; if (Array.isArray(value) && value.length > 1) { return `"${value.join('|')}"`; diff --git a/public/app/plugins/datasource/tempo/dataquery.cue b/public/app/plugins/datasource/tempo/dataquery.cue index c87f5eddb9e..e98a79c805b 100644 --- a/public/app/plugins/datasource/tempo/dataquery.cue +++ b/public/app/plugins/datasource/tempo/dataquery.cue @@ -84,6 +84,8 @@ composableKinds: DataQuery: { valueType?: string // The scope of the filter, can either be unscoped/all scopes, resource or span scope?: #TraceqlSearchScope + // Whether the value is a custom value typed by the user + isCustomValue?: bool } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/datasource/tempo/dataquery.gen.ts b/public/app/plugins/datasource/tempo/dataquery.gen.ts index f55757b5a47..5b6af06f127 100644 --- a/public/app/plugins/datasource/tempo/dataquery.gen.ts +++ b/public/app/plugins/datasource/tempo/dataquery.gen.ts @@ -123,6 +123,10 @@ export interface TraceqlFilter { * Uniquely identify the filter, will not be used in the query generation */ id: string; + /** + * Whether the value is a custom value typed by the user + */ + isCustomValue?: boolean; /** * The operator that connects the tag to the value, for example: =, >, !=, =~ */