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
This commit is contained in:
Joey
2025-06-18 12:21:45 +01:00
committed by GitHub
parent 0270152e35
commit ecf793ea05
7 changed files with 111 additions and 6 deletions

View File

@ -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: =, >, !=, =~
*/

View File

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

View File

@ -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}
/>
);
};

View File

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

View File

@ -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('|')}"`;

View File

@ -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")
}
}]

View File

@ -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: =, >, !=, =~
*/