Tempo: TraceQL Configurable static fields (#65284)

* TraceQL - configurable static fields for new UI

* TraceQL - filter out static fields from Tags section. Added tooltip to static fields

* Add more units to duration validation. Improve duration field tooltip with accepted units

* Better control of delete button on SearchField

* Move new config behind feature toggle

* Special title for intrinsic "name"

* Fix tests

* Move static fields not in the datasource to the Tags section

* Start using the useAsync hook in the Tempo TraceQL configuration page to retrieve the tags and datasource

* Fix tests

* Fix test. Use useAsync to retrieve options in SearchField

* Remove ability to set a default value in filter configuration.
Removed type from filter, dynamic filters are now any filters not present in the datasource config

* Updated the static filters tooltip

* Replace useState + useEffect with useMemo for scopedTag
This commit is contained in:
Andre Pereira 2023-03-31 10:35:37 +01:00 committed by GitHub
parent fb83414b6a
commit 541a03f33b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 425 additions and 222 deletions

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ interface Props {
const SearchField = ({ label, tooltip, children }: Props) => {
return (
<InlineFieldRow>
<InlineField label={label} labelWidth={16} grow tooltip={tooltip}>
<InlineField label={label} labelWidth={28} grow tooltip={tooltip}>
{children}
</InlineField>
</InlineFieldRow>

View File

@ -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(
<SearchField
datasource={{} as TempoDatasource}
datasource={datasource}
updateFilter={updateFilter}
filter={filter}
setError={function (error: FetchError): void {
throw error;
}}
tags={tags || []}
hideTag={hideTag}
/>
);
};

View File

@ -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<Array<SelectableValue<string>>>([]);
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 (
<HorizontalGroup spacing={'none'} width={'auto'}>
{filter.type === 'dynamic' && (
{!hideScope && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-scope`}
@ -114,12 +118,16 @@ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoa
aria-label={`select ${filter.id} scope`}
/>
)}
{filter.type === 'dynamic' && (
{!hideTag && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-tag`}
isLoading={isTagsLoading}
options={tags.map((t) => ({ 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}
/>
<Select
className={styles.dropdown}
inputId={`${filter.id}-value`}
isLoading={isLoadingValues}
options={options}
value={filter.value}
onChange={(val) => {
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
/>
{filter.type === 'dynamic' && (
{!hideValue && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-value`}
isLoading={isLoadingValues}
options={options}
value={filter.value}
onChange={(val) => {
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 && (
<AccessoryButton
variant={'secondary'}
icon={'times'}

View File

@ -32,26 +32,34 @@ interface Props {
setError: (error: FetchError) => 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 (
<div className={styles.vertical}>
{dynamicFilters?.map((f, i) => (
{filters?.map((f, i) => (
<div className={styles.horizontal} key={f.id}>
<SearchField
filter={f}
@ -61,8 +69,10 @@ const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError,
tags={tags}
isTagsLoading={isTagsLoading}
deleteFilter={deleteFilter}
allowDelete={true}
hideValue={hideValues}
/>
{i === dynamicFilters.length - 1 && (
{i === filters.length - 1 && (
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
)}
</div>

View File

@ -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<typeof userEvent.setup>;
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(
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
);
const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
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(
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
);
const { container } = render(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
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(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
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(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
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);
}
});
});

View File

@ -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<string>('');
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 (
<>
<div className={styles.container}>
<div>
<InlineSearchField label={'Service Name'}>
<SearchField
filter={
findFilter('service-name') || {
id: 'service-name',
type: 'static',
tag: 'service.name',
operator: '=',
scope: TraceqlSearchScope.Resource,
}
}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={[]}
/>
</InlineSearchField>
<InlineSearchField label={'Span Name'}>
<SearchField
filter={findFilter('span-name') || { id: 'span-name', type: 'static', tag: 'name', operator: '=' }}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={[]}
/>
</InlineSearchField>
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
{datasource.search?.filters?.map((f) => (
<InlineSearchField
key={f.id}
label={filterTitle(f)}
tooltip={`Filter your search by ${filterScopedTag(
f
)}. To modify the default filters shown for search visit the Tempo datasource configuration page.`}
>
<SearchField
filter={findFilter(f.id) || f}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={[]}
hideScope={true}
hideTag={true}
/>
</InlineSearchField>
))}
<InlineSearchField
label={'Duration'}
tooltip="The span duration, i.e. end - start time of the span. Accepted units are ns, ms, s, m, h"
>
<HorizontalGroup spacing={'sm'}>
<DurationInput
filter={
findFilter('min-duration') || {
id: 'min-duration',
type: 'static',
tag: 'duration',
operator: '>',
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) => {
</InlineSearchField>
<InlineSearchField label={'Tags'}>
<TagsInput
filters={query.filters}
filters={dynamicFilters}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
deleteFilter={deleteFilter}
tags={[...CompletionProvider.intrinsics, ...tags]}
tags={filteredTags}
isTagsLoading={isTagsLoading}
/>
</InlineSearchField>

View File

@ -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: '>=',

View File

@ -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<T>(array: T[], index: number, value: T) {
const ret = array.slice(0);
ret[index] = value;

View File

@ -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) => {
</div>
<div className="gf-form-group">
<SearchSettings options={options} onOptionsChange={onOptionsChange} />
{config.featureToggles.traceqlSearch ? (
<TraceQLSearchSettings options={options} onOptionsChange={onOptionsChange} />
) : (
<SearchSettings options={options} onOptionsChange={onOptionsChange} />
)}
</div>
<div className="gf-form-group">

View File

@ -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<TempoJsonData> {}
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 (
<div className={styles.container}>
<h3 className="page-heading">Tempo search</h3>
<InlineFieldRow className={styles.row}>
<InlineField tooltip="Removes the search tab from the query editor" label="Hide search" labelWidth={26}>
<InlineSwitch
id="hideSearch"
value={options.jsonData.search?.hide}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'search', {
...options.jsonData.search,
hide: event.currentTarget.checked,
})
}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow className={styles.row}>
<InlineField tooltip="Configures which fields are available in the UI" label="Static filters" labelWidth={26}>
<TraceQLSearchTags datasource={datasource} options={options} onOptionsChange={onOptionsChange} />
</InlineField>
</InlineFieldRow>
</div>
);
}
const styles = {
container: css`
label: container;
width: 100%;
`,
row: css`
label: row;
align-items: baseline;
`,
};

View File

@ -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<TempoJsonData> {
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 ? (
<TagsInput
updateFilter={updateFilter}
deleteFilter={deleteFilter}
filters={options.jsonData.search?.filters || []}
datasource={datasource}
setError={() => {}}
tags={[...CompletionProvider.intrinsics, ...(tags || [])]}
isTagsLoading={loading}
hideValues={true}
/>
) : (
<div>Invalid data source, please create a valid data source and try again</div>
)}
{error && (
<Alert title={'Unable to fetch TraceQL tags'} severity={'error'} topSpacing={1}>
{error.message}
</Alert>
)}
</>
);
}

View File

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

View File

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

View File

@ -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<TempoQuery, TempoJson
};
search?: {
hide?: boolean;
filters?: TraceqlFilter[];
};
nodeGraph?: NodeGraphOptions;
lokiSearch?: {
@ -92,6 +94,20 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
this.lokiSearch = instanceSettings.jsonData.lokiSearch;
this.traceQuery = instanceSettings.jsonData.traceQuery;
this.languageProvider = new TempoLanguageProvider(this);
if (!this.search?.filters) {
this.search = {
...this.search,
filters: [
{
id: 'service-name',
tag: 'service.name',
operator: '=',
scope: TraceqlSearchScope.Resource,
},
{ id: 'span-name', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span },
],
};
}
}
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {

View File

@ -4,7 +4,7 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsS
import { LokiQuery } from '../loki/types';
import { TempoQuery as TempoBase, TempoQueryType } from './dataquery.gen';
import { TempoQuery as TempoBase, TempoQueryType, TraceqlFilter } from './dataquery.gen';
export interface SearchQueryParams {
minDuration?: string;
@ -22,6 +22,7 @@ export interface TempoJsonData extends DataSourceJsonData {
};
search?: {
hide?: boolean;
filters?: TraceqlFilter[];
};
nodeGraph?: NodeGraphOptions;
lokiSearch?: {