diff --git a/.betterer.results b/.betterer.results index 33eb03face1..b4be81394ee 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4994,6 +4994,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/plugins/datasource/tempo/datasource.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index 61cbca5db7b..9af96c2bbbe 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -16,12 +16,6 @@ const ( TempoQueryFiltersScopeUnscoped TempoQueryFiltersScope = "unscoped" ) -// Defines values for TempoQueryFiltersType. -const ( - TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic" - TempoQueryFiltersTypeStatic TempoQueryFiltersType = "static" -) - // Defines values for TempoQueryType. const ( TempoQueryTypeClear TempoQueryType = "clear" @@ -40,18 +34,6 @@ const ( TraceqlFilterScopeUnscoped TraceqlFilterScope = "unscoped" ) -// Defines values for TraceqlFilterType. -const ( - TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic" - TraceqlFilterTypeStatic TraceqlFilterType = "static" -) - -// Defines values for TraceqlSearchFilterType. -const ( - TraceqlSearchFilterTypeDynamic TraceqlSearchFilterType = "dynamic" - TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static" -) - // Defines values for TraceqlSearchScope. const ( TraceqlSearchScopeResource TraceqlSearchScope = "resource" @@ -82,9 +64,6 @@ type TempoQuery struct { // The tag for the search filter, for example: .http.status_code, .service.name, status Tag *string `json:"tag,omitempty"` - // The type of the filter, can either be static (pre defined in the UI) or dynamic - Type TempoQueryFiltersType `json:"type"` - // The value for the search filter Value *interface{} `json:"value,omitempty"` @@ -134,9 +113,6 @@ type TempoQuery struct { // The scope of the filter, can either be unscoped/all scopes, resource or span type TempoQueryFiltersScope string -// The type of the filter, can either be static (pre defined in the UI) or dynamic -type TempoQueryFiltersType string - // TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility type TempoQueryType string @@ -154,9 +130,6 @@ type TraceqlFilter struct { // The tag for the search filter, for example: .http.status_code, .service.name, status Tag *string `json:"tag,omitempty"` - // The type of the filter, can either be static (pre defined in the UI) or dynamic - Type TraceqlFilterType `json:"type"` - // The value for the search filter Value *interface{} `json:"value,omitempty"` @@ -167,11 +140,5 @@ type TraceqlFilter struct { // The scope of the filter, can either be unscoped/all scopes, resource or span type TraceqlFilterScope string -// The type of the filter, can either be static (pre defined in the UI) or dynamic -type TraceqlFilterType string - -// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user -type TraceqlSearchFilterType string - -// TraceqlSearchScope defines model for TraceqlSearchScope. +// TraceqlSearchScope static fields are pre-set in the UI, dynamic fields are added by the user type TraceqlSearchScope string diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx index 7ffd740dd56..b65d6033a9c 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx @@ -13,7 +13,7 @@ interface Props { operators: string[]; } -const validationRegex = /^\d+(?:\.\d)?\d*(?:ms|s|ns)$/; +const validationRegex = /^\d+(?:\.\d)?\d*(?:us|µs|ns|ms|s|m|h)$/; const DurationInput = ({ filter, operators, updateFilter }: Props) => { let invalid = false; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx index 3f80a857c95..abe948dddcd 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx @@ -10,7 +10,7 @@ interface Props { const SearchField = ({ label, tooltip, children }: Props) => { return ( - + {children} diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx index a17c34939b8..9af673c1ad9 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { FetchError } from '@grafana/runtime'; -import { TraceqlFilter } from '../dataquery.gen'; +import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import SearchField from './SearchField'; @@ -48,12 +48,13 @@ describe('SearchField', () => { jest.useRealTimers(); }); - it('should not render tag if tag is present in field', () => { + it('should not render tag if hideTag is true', () => { const updateFilter = jest.fn((val) => { return val; }); - const filter: TraceqlFilter = { id: 'test1', type: 'static', valueType: 'string', tag: 'test-tag' }; - const { container } = renderSearchField(updateFilter, filter); + const filter: TraceqlFilter = { id: 'test1', valueType: 'string', tag: 'test-tag' }; + + const { container } = renderSearchField(updateFilter, filter, [], true); expect(container.querySelector(`input[aria-label="select test1 tag"]`)).not.toBeInTheDocument(); expect(container.querySelector(`input[aria-label="select test1 operator"]`)).toBeInTheDocument(); @@ -64,7 +65,7 @@ describe('SearchField', () => { const updateFilter = jest.fn((val) => { return val; }); - const filter: TraceqlFilter = { id: 'test1', operator: '=', type: 'static', valueType: 'string', tag: 'test-tag' }; + const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' }; const { container } = renderSearchField(updateFilter, filter); const select = await container.querySelector(`input[aria-label="select test1 operator"]`); @@ -87,7 +88,6 @@ describe('SearchField', () => { const filter: TraceqlFilter = { id: 'test1', value: 'old', - type: 'static', valueType: 'string', tag: 'test-tag', }; @@ -124,7 +124,6 @@ describe('SearchField', () => { }); const filter: TraceqlFilter = { id: 'test1', - type: 'dynamic', valueType: 'string', }; const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']); @@ -155,16 +154,35 @@ describe('SearchField', () => { }); }); -const renderSearchField = (updateFilter: (f: TraceqlFilter) => void, filter: TraceqlFilter, tags?: string[]) => { +const renderSearchField = ( + updateFilter: (f: TraceqlFilter) => void, + filter: TraceqlFilter, + tags?: string[], + hideTag?: boolean +) => { + const datasource: TempoDatasource = { + search: { + filters: [ + { + id: 'service-name', + tag: 'service.name', + operator: '=', + scope: TraceqlSearchScope.Resource, + }, + { id: 'span-name', type: 'static', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span }, + ], + }, + } as TempoDatasource; return render( ); }; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx index ce7c969274b..a5fb47c4740 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { uniq } from 'lodash'; +import React, { useState, useEffect, useMemo } from 'react'; +import useAsync from 'react-use/lib/useAsync'; -import { SelectableValue } from '@grafana/data'; import { AccessoryButton } from '@grafana/experimental'; import { FetchError, isFetchError } from '@grafana/runtime'; import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui'; @@ -14,7 +15,7 @@ import { TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; import { operators as allOperators, stringOperators, numberOperators } from '../traceql/traceql'; -import { operatorSelectableValue, scopeHelper } from './utils'; +import { filterScopedTag, operatorSelectableValue } from './utils'; const getStyles = () => ({ dropdown: css` @@ -30,19 +31,49 @@ interface Props { setError: (error: FetchError) => void; isTagsLoading?: boolean; tags: string[]; + hideScope?: boolean; + hideTag?: boolean; + hideValue?: boolean; + allowDelete?: boolean; } -const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoading, tags, setError }: Props) => { +const SearchField = ({ + filter, + datasource, + updateFilter, + deleteFilter, + isTagsLoading, + tags, + setError, + hideScope, + hideTag, + hideValue, + allowDelete, +}: Props) => { const styles = useStyles2(getStyles); const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); - const [isLoadingValues, setIsLoadingValues] = useState(false); - const [options, setOptions] = useState>>([]); - const [scopedTag, setScopedTag] = useState(scopeHelper(filter) + filter.tag); + const scopedTag = useMemo(() => filterScopedTag(filter), [filter]); // We automatically change the operator to the regex op when users select 2 or more values // However, they expect this to be automatically rolled back to the previous operator once // there's only one value selected, so we store the previous operator and value const [prevOperator, setPrevOperator] = useState(filter.operator); const [prevValue, setPrevValue] = useState(filter.value); + const updateOptions = async () => { + try { + return await languageProvider.getOptionsV2(scopedTag); + } catch (error) { + // Display message if Tempo is connected but search 404's + if (isFetchError(error) && error?.status === 404) { + setError(error); + } else if (error instanceof Error) { + dispatch(notifyApp(createErrorNotification('Error', error))); + } + } + return []; + }; + + const { loading: isLoadingValues, value: options } = useAsync(updateOptions, [scopedTag, languageProvider, setError]); + useEffect(() => { if (Array.isArray(filter.value) && filter.value.length > 1 && filter.operator !== '=~') { setPrevOperator(filter.operator); @@ -57,38 +88,11 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa setPrevValue(filter.value); }, [filter.value]); - useEffect(() => { - const newScopedTag = scopeHelper(filter) + filter.tag; - if (newScopedTag !== scopedTag) { - setScopedTag(newScopedTag); - } - }, [filter, scopedTag]); - - const updateOptions = useCallback(async () => { - try { - setIsLoadingValues(true); - setOptions(await languageProvider.getOptionsV2(scopedTag)); - } catch (error) { - // Display message if Tempo is connected but search 404's - if (isFetchError(error) && error?.status === 404) { - setError(error); - } else if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); - } - } finally { - setIsLoadingValues(false); - } - }, [scopedTag, languageProvider, setError]); - - useEffect(() => { - updateOptions(); - }, [updateOptions]); - const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t })); // If all values have type string or int/float use a focused list of operators instead of all operators - const optionsOfFirstType = options.filter((o) => o.type === options[0]?.type); - const uniqueOptionType = options.length === optionsOfFirstType.length ? options[0]?.type : undefined; + const optionsOfFirstType = options?.filter((o) => o.type === options[0]?.type); + const uniqueOptionType = options?.length === optionsOfFirstType?.length ? options?.[0]?.type : undefined; let operatorList = allOperators; switch (uniqueOptionType) { case 'string': @@ -101,7 +105,7 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa return ( - {filter.type === 'dynamic' && ( + {!hideScope && ( ({ label: t, value: t }))} + // Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value + options={(filter.tag !== undefined ? uniq([filter.tag, ...tags]) : tags).map((t) => ({ + label: t, + value: t, + }))} value={filter.tag} onChange={(v) => { updateFilter({ ...filter, tag: v?.value }); @@ -143,26 +151,28 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa allowCustomValue={true} width={8} /> - { + if (Array.isArray(val)) { + updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type }); + } else { + updateFilter({ ...filter, value: val?.value, valueType: val?.type }); + } + }} + placeholder="Select value" + isClearable={false} + aria-label={`select ${filter.id} value`} + allowCustomValue={true} + isMulti + /> + )} + {allowDelete && ( void; tags: string[]; isTagsLoading: boolean; + hideValues?: boolean; } -const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => { +const TagsInput = ({ + updateFilter, + deleteFilter, + filters, + datasource, + setError, + tags, + isTagsLoading, + hideValues, +}: Props) => { const styles = useStyles2(getStyles); const generateId = () => uuidv4().slice(0, 8); const handleOnAdd = useCallback( - () => updateFilter({ id: generateId(), type: 'dynamic', operator: '=', scope: TraceqlSearchScope.Span }), + () => updateFilter({ id: generateId(), operator: '=', scope: TraceqlSearchScope.Span }), [updateFilter] ); useEffect(() => { - if (!filters?.find((f) => f.type === 'dynamic')) { + if (!filters?.length) { handleOnAdd(); } }, [filters, handleOnAdd]); - const dynamicFilters = filters?.filter((f) => f.type === 'dynamic'); - return (
- {dynamicFilters?.map((f, i) => ( + {filters?.map((f, i) => (
- {i === dynamicFilters.length - 1 && ( + {i === filters.length - 1 && ( )}
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx index 212e5e61000..45956b09334 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -40,12 +40,25 @@ jest.mock('../language_provider', () => { describe('TraceQLSearch', () => { let user: ReturnType; + const datasource: TempoDatasource = { + search: { + filters: [ + { + id: 'service-name', + tag: 'service.name', + operator: '=', + scope: TraceqlSearchScope.Resource, + }, + ], + }, + } as TempoDatasource; + let query: TempoQuery = { refId: 'A', queryType: 'traceqlSearch', key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', query: '', - filters: [{ id: 'min-duration', operator: '>', type: 'static', valueType: 'duration', tag: 'duration' }], + filters: [{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration' }], }; const onChange = (q: TempoQuery) => { query = q; @@ -63,14 +76,11 @@ describe('TraceQLSearch', () => { }); it('should update operator when new value is selected in operator input', async () => { - const { container } = render( - - ); + const { container } = render(); const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`); expect(minDurationOperator).not.toBeNull(); expect(minDurationOperator).toBeInTheDocument(); - expect(await screen.findByText('>')).toBeInTheDocument(); if (minDurationOperator) { await user.click(minDurationOperator); @@ -84,9 +94,7 @@ describe('TraceQLSearch', () => { }); it('should add new filter when new value is selected in the service name section', async () => { - const { container } = render( - - ); + const { container } = render(); const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`); expect(serviceNameValue).not.toBeNull(); expect(serviceNameValue).toBeInTheDocument(); @@ -106,28 +114,4 @@ describe('TraceQLSearch', () => { expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource); } }); - - it('should add new filter when new filter button is clicked and remove filter when remove button is clicked', async () => { - render(); - - const dynamicFilters = query.filters.filter((f) => f.type === 'dynamic'); - expect(dynamicFilters.length).toBe(1); - const addButton = await screen.findByTitle('Add tag'); - await user.click(addButton); - jest.advanceTimersByTime(1000); - - // We have to rerender here so it picks up the new dynamic field - render(); - - const newDynamicFilters = query.filters.filter((f) => f.type === 'dynamic'); - expect(newDynamicFilters.length).toBe(2); - - const notInitialDynamic = newDynamicFilters.find((f) => f.id !== dynamicFilters[0].id); - const secondDynamicRemoveButton = await screen.findByLabelText(`remove tag with ID ${notInitialDynamic?.id}`); - await waitFor(() => expect(secondDynamicRemoveButton).toBeInTheDocument()); - if (secondDynamicRemoveButton) { - await user.click(secondDynamicRemoveButton); - expect(query.filters.filter((f) => f.type === 'dynamic')).toStrictEqual(dynamicFilters); - } - }); }); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx index 8d775d898ca..dfe9e398dbe 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { EditorRow } from '@grafana/experimental'; @@ -10,7 +10,7 @@ import { createErrorNotification } from '../../../../core/copy/appNotification'; import { notifyApp } from '../../../../core/reducers/appNotification'; import { dispatch } from '../../../../store/store'; import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; -import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; +import { TraceqlFilter } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions'; import { CompletionProvider } from '../traceql/autocomplete'; @@ -21,7 +21,7 @@ import DurationInput from './DurationInput'; import InlineSearchField from './InlineSearchField'; import SearchField from './SearchField'; import TagsInput from './TagsInput'; -import { generateQueryFromFilters, replaceAt } from './utils'; +import { filterScopedTag, filterTitle, generateQueryFromFilters, replaceAt } from './utils'; interface Props { datasource: TempoDatasource; @@ -38,18 +38,21 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { const [isTagsLoading, setIsTagsLoading] = useState(true); const [traceQlQuery, setTraceQlQuery] = useState(''); - const updateFilter = (s: TraceqlFilter) => { - const copy = { ...query }; - copy.filters ||= []; - const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id); - if (indexOfFilter >= 0) { - // update in place if the filter already exists, for consistency and to avoid UI bugs - copy.filters = replaceAt(copy.filters, indexOfFilter, s); - } else { - copy.filters.push(s); - } - onChange(copy); - }; + const updateFilter = useCallback( + (s: TraceqlFilter) => { + const copy = { ...query }; + copy.filters ||= []; + const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id); + if (indexOfFilter >= 0) { + // update in place if the filter already exists, for consistency and to avoid UI bugs + copy.filters = replaceAt(copy.filters, indexOfFilter, s); + } else { + copy.filters.push(s); + } + onChange(copy); + }, + [onChange, query] + ); const deleteFilter = (s: TraceqlFilter) => { onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) }); @@ -59,7 +62,7 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { setTraceQlQuery(generateQueryFromFilters(query.filters || [])); }, [query]); - const findFilter = (id: string) => query.filters?.find((f) => f.id === id); + const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]); useEffect(() => { const fetchTags = async () => { @@ -85,43 +88,60 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { fetchTags(); }, [datasource]); + useEffect(() => { + // Initialize state with configured static filters that already have a value from the config + datasource.search?.filters + ?.filter((f) => f.value) + .forEach((f) => { + if (!findFilter(f.id)) { + updateFilter(f); + } + }); + }, [datasource.search?.filters, findFilter, updateFilter]); + + // filter out tags that already exist in the static fields + const staticTags = datasource.search?.filters?.map((f) => f.tag) || []; + staticTags.push('duration'); + const filteredTags = [...CompletionProvider.intrinsics, ...tags].filter((t) => !staticTags.includes(t)); + + // Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration + // The duration tag is a special case since its selector is hard-coded + const dynamicFilters = (query.filters || []).filter( + (f) => f.tag !== 'duration' && (datasource.search?.filters?.findIndex((sf) => sf.id === f.id) || 0) === -1 + ); + return ( <>
- - - + {datasource.search?.filters?.map((f) => ( + + + + ))} + ', valueType: 'duration', @@ -134,7 +154,6 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { filter={ findFilter('max-duration') || { id: 'max-duration', - type: 'static', tag: 'duration', operator: '<', valueType: 'duration', @@ -147,12 +166,12 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts index e0c48e7a8e8..603cd113f10 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts @@ -8,51 +8,49 @@ describe('generateQueryFromFilters generates the correct query for', () => { }); it('a field without value', () => { - expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', operator: '=' }])).toBe('{}'); + expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', operator: '=' }])).toBe('{}'); }); it('a field with value but without tag', () => { - expect(generateQueryFromFilters([{ id: 'foo', type: 'static', value: 'foovalue', operator: '=' }])).toBe('{}'); + expect(generateQueryFromFilters([{ id: 'foo', value: 'foovalue', operator: '=' }])).toBe('{}'); }); it('a field with value and tag but without operator', () => { - expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue' }])).toBe('{}'); + expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}'); }); it('a field with tag, operator and tag', () => { - expect( - generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }]) - ).toBe('{.footag="foovalue"}'); + expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe( + '{.footag="foovalue"}' + ); }); it('a field with valueType as integer', () => { expect( - generateQueryFromFilters([ - { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }, - ]) + generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }]) ).toBe('{.footag>1234}'); }); it('two fields with everything filled in', () => { expect( generateQueryFromFilters([ - { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, - { id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' }, + { id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, + { id: 'bar', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' }, ]) ).toBe('{.footag>=1234 && .bartag="barvalue"}'); }); it('two fields but one is missing a value', () => { expect( generateQueryFromFilters([ - { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, - { id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' }, + { id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, + { id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' }, ]) ).toBe('{.footag>=1234}'); }); it('two fields but one is missing a value and the other a tag', () => { expect( generateQueryFromFilters([ - { id: 'foo', type: 'static', value: '1234', operator: '>=', valueType: 'integer' }, - { id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' }, + { id: 'foo', value: '1234', operator: '>=', valueType: 'integer' }, + { id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' }, ]) ).toBe('{}'); }); @@ -61,7 +59,6 @@ describe('generateQueryFromFilters generates the correct query for', () => { generateQueryFromFilters([ { id: 'foo', - type: 'static', tag: 'footag', value: '1234', operator: '>=', @@ -76,7 +73,6 @@ describe('generateQueryFromFilters generates the correct query for', () => { generateQueryFromFilters([ { id: 'foo', - type: 'static', tag: 'footag', value: '1234', operator: '>=', @@ -91,7 +87,6 @@ describe('generateQueryFromFilters generates the correct query for', () => { generateQueryFromFilters([ { id: 'foo', - type: 'static', tag: 'footag', value: '1234', operator: '>=', diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts index 22cc5333e25..a0fcd660123 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts @@ -1,3 +1,5 @@ +import { startCase } from 'lodash'; + import { SelectableValue } from '@grafana/data'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; @@ -19,7 +21,7 @@ const valueHelper = (f: TraceqlFilter) => { } return f.value; }; -export const scopeHelper = (f: TraceqlFilter) => { +const scopeHelper = (f: TraceqlFilter) => { // Intrinsic fields don't have a scope if (CompletionProvider.intrinsics.find((t) => t === f.tag)) { return ''; @@ -29,6 +31,18 @@ export const scopeHelper = (f: TraceqlFilter) => { ); }; +export const filterScopedTag = (f: TraceqlFilter) => { + return scopeHelper(f) + f.tag; +}; + +export const filterTitle = (f: TraceqlFilter) => { + // Special case for the intrinsic "name" since a label called "Name" isn't explicit + if (f.tag === 'name') { + return 'Span Name'; + } + return startCase(filterScopedTag(f)); +}; + export function replaceAt(array: T[], index: number, value: T) { const ret = array.slice(0); ret[index] = value; diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 91622f12bbf..ee127a012b7 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -12,6 +12,7 @@ import { LokiSearchSettings } from './LokiSearchSettings'; import { QuerySettings } from './QuerySettings'; import { SearchSettings } from './SearchSettings'; import { ServiceGraphSettings } from './ServiceGraphSettings'; +import { TraceQLSearchSettings } from './TraceQLSearchSettings'; export type Props = DataSourcePluginOptionsEditorProps; @@ -48,7 +49,11 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => {
- + {config.featureToggles.traceqlSearch ? ( + + ) : ( + + )}
diff --git a/public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx b/public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx new file mode 100644 index 00000000000..0a2ab3cea5f --- /dev/null +++ b/public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx @@ -0,0 +1,59 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui'; + +import { TempoDatasource } from '../datasource'; +import { TempoJsonData } from '../types'; + +import { TraceQLSearchTags } from './TraceQLSearchTags'; + +interface Props extends DataSourcePluginOptionsEditorProps {} + +export function TraceQLSearchSettings({ options, onOptionsChange }: Props) { + const dataSourceSrv = getDataSourceSrv(); + const fetchDatasource = async () => { + return (await dataSourceSrv.get({ type: options.type, uid: options.uid })) as TempoDatasource; + }; + + const { value: datasource } = useAsync(fetchDatasource, [dataSourceSrv, options]); + + return ( +
+

Tempo search

+ + + ) => + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', { + ...options.jsonData.search, + hide: event.currentTarget.checked, + }) + } + /> + + + + + + + +
+ ); +} + +const styles = { + container: css` + label: container; + width: 100%; + `, + row: css` + label: row; + align-items: baseline; + `, +}; diff --git a/public/app/plugins/datasource/tempo/configuration/TraceQLSearchTags.tsx b/public/app/plugins/datasource/tempo/configuration/TraceQLSearchTags.tsx new file mode 100644 index 00000000000..b90e61a2783 --- /dev/null +++ b/public/app/plugins/datasource/tempo/configuration/TraceQLSearchTags.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; +import { Alert } from '@grafana/ui'; + +import TagsInput from '../SearchTraceQLEditor/TagsInput'; +import { replaceAt } from '../SearchTraceQLEditor/utils'; +import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; +import { TempoDatasource } from '../datasource'; +import { CompletionProvider } from '../traceql/autocomplete'; +import { TempoJsonData } from '../types'; + +interface Props extends DataSourcePluginOptionsEditorProps { + datasource?: TempoDatasource; +} + +export function TraceQLSearchTags({ options, onOptionsChange, datasource }: Props) { + const fetchTags = async () => { + if (!datasource) { + throw new Error('Unable to retrieve datasource'); + } + + try { + await datasource.languageProvider.start(); + const tags = datasource.languageProvider.getTags(); + + if (tags) { + // This is needed because the /api/v2/search/tag/${tag}/values API expects "status" and the v1 API expects "status.code" + // so Tempo doesn't send anything and we inject it here for the autocomplete + if (!tags.find((t) => t === 'status')) { + tags.push('status'); + } + return tags; + } + } catch (e) { + // @ts-ignore + throw new Error(`${e.statusText}: ${e.data.error}`); + } + return []; + }; + + const { error, loading, value: tags } = useAsync(fetchTags, [datasource, options]); + + const updateFilter = useCallback( + (s: TraceqlFilter) => { + let copy = options.jsonData.search?.filters; + copy ||= []; + const indexOfFilter = copy.findIndex((f) => f.id === s.id); + if (indexOfFilter >= 0) { + // update in place if the filter already exists, for consistency and to avoid UI bugs + copy = replaceAt(copy, indexOfFilter, s); + } else { + copy.push(s); + } + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', { + ...options.jsonData.search, + filters: copy, + }); + }, + [onOptionsChange, options] + ); + + const deleteFilter = (s: TraceqlFilter) => { + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', { + ...options.jsonData.search, + filters: options.jsonData.search?.filters?.filter((f) => f.id !== s.id), + }); + }; + + useEffect(() => { + if (!options.jsonData.search?.filters) { + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', { + ...options.jsonData.search, + filters: [ + { + id: 'service-name', + tag: 'service.name', + operator: '=', + scope: TraceqlSearchScope.Resource, + }, + { id: 'span-name', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span }, + ], + }); + } + }, [onOptionsChange, options]); + + return ( + <> + {datasource ? ( + {}} + tags={[...CompletionProvider.intrinsics, ...(tags || [])]} + isTagsLoading={loading} + hideValues={true} + /> + ) : ( +
Invalid data source, please create a valid data source and try again
+ )} + {error && ( + + {error.message} + + )} + + ); +} diff --git a/public/app/plugins/datasource/tempo/dataquery.cue b/public/app/plugins/datasource/tempo/dataquery.cue index a5c7170fb27..93f6161c0a9 100644 --- a/public/app/plugins/datasource/tempo/dataquery.cue +++ b/public/app/plugins/datasource/tempo/dataquery.cue @@ -54,13 +54,10 @@ composableKinds: DataQuery: { #TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type") // static fields are pre-set in the UI, dynamic fields are added by the user - #TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type") - #TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum") + #TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum") #TraceqlFilter: { // Uniquely identify the filter, will not be used in the query generation id: string - // The type of the filter, can either be static (pre defined in the UI) or dynamic - type: #TraceqlSearchFilterType // The tag for the search filter, for example: .http.status_code, .service.name, status tag?: string // The operator that connects the tag to the value, for example: =, >, !=, =~ diff --git a/public/app/plugins/datasource/tempo/dataquery.gen.ts b/public/app/plugins/datasource/tempo/dataquery.gen.ts index 4578ae229fe..548bc3e78b1 100644 --- a/public/app/plugins/datasource/tempo/dataquery.gen.ts +++ b/public/app/plugins/datasource/tempo/dataquery.gen.ts @@ -60,8 +60,6 @@ export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceM /** * static fields are pre-set in the UI, dynamic fields are added by the user */ -export type TraceqlSearchFilterType = ('static' | 'dynamic'); - export enum TraceqlSearchScope { Resource = 'resource', Span = 'span', @@ -85,10 +83,6 @@ export interface TraceqlFilter { * The tag for the search filter, for example: .http.status_code, .service.name, status */ tag?: string; - /** - * The type of the filter, can either be static (pre defined in the UI) or dynamic - */ - type: TraceqlSearchFilterType; /** * The value for the search filter */ diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index c33aaa376cc..14e2598a55e 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -36,6 +36,7 @@ import { PrometheusDatasource } from '../prometheus/datasource'; import { PromQuery } from '../prometheus/types'; import { generateQueryFromFilters } from './SearchTraceQLEditor/utils'; +import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; import { failedMetric, histogramMetric, @@ -66,6 +67,7 @@ export class TempoDatasource extends DataSourceWithBackend