Tempo: Search using TraceQL improvements (#64616)

* Added scope to filter

* Intrinsic fields don't have scope

* Consistent plus button placement next to the last tag. Changed All Scopes to "unscoped"

* Added validation to duration fields

* Disable options load when dropdown is opened

* Use focused list of operators when all values are of type string or int/float

* Fixed and added tests

* Fix another test

* Better way to prevent duplicate and redundant backend requests when a filter updates
This commit is contained in:
Andre Pereira 2023-03-21 15:59:16 +00:00 committed by GitHub
parent 6093e45178
commit bfb0dde4a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 243 additions and 98 deletions

View File

@ -9,6 +9,13 @@
package dataquery package dataquery
// Defines values for TempoQueryFiltersScope.
const (
TempoQueryFiltersScopeResource TempoQueryFiltersScope = "resource"
TempoQueryFiltersScopeSpan TempoQueryFiltersScope = "span"
TempoQueryFiltersScopeUnscoped TempoQueryFiltersScope = "unscoped"
)
// Defines values for TempoQueryFiltersType. // Defines values for TempoQueryFiltersType.
const ( const (
TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic" TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic"
@ -26,6 +33,13 @@ const (
TempoQueryTypeUpload TempoQueryType = "upload" TempoQueryTypeUpload TempoQueryType = "upload"
) )
// Defines values for TraceqlFilterScope.
const (
TraceqlFilterScopeResource TraceqlFilterScope = "resource"
TraceqlFilterScopeSpan TraceqlFilterScope = "span"
TraceqlFilterScopeUnscoped TraceqlFilterScope = "unscoped"
)
// Defines values for TraceqlFilterType. // Defines values for TraceqlFilterType.
const ( const (
TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic" TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic"
@ -38,6 +52,13 @@ const (
TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static" TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static"
) )
// Defines values for TraceqlSearchScope.
const (
TraceqlSearchScopeResource TraceqlSearchScope = "resource"
TraceqlSearchScopeSpan TraceqlSearchScope = "span"
TraceqlSearchScopeUnscoped TraceqlSearchScope = "unscoped"
)
// TempoDataQuery defines model for TempoDataQuery. // TempoDataQuery defines model for TempoDataQuery.
type TempoDataQuery = map[string]interface{} type TempoDataQuery = map[string]interface{}
@ -55,6 +76,9 @@ type TempoQuery struct {
// The operator that connects the tag to the value, for example: =, >, !=, =~ // The operator that connects the tag to the value, for example: =, >, !=, =~
Operator *string `json:"operator,omitempty"` Operator *string `json:"operator,omitempty"`
// The scope of the filter, can either be unscoped/all scopes, resource or span
Scope *TempoQueryFiltersScope `json:"scope,omitempty"`
// The tag for the search filter, for example: .http.status_code, .service.name, status // The tag for the search filter, for example: .http.status_code, .service.name, status
Tag *string `json:"tag,omitempty"` Tag *string `json:"tag,omitempty"`
@ -107,6 +131,9 @@ type TempoQuery struct {
SpanName *string `json:"spanName,omitempty"` SpanName *string `json:"spanName,omitempty"`
} }
// 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 // The type of the filter, can either be static (pre defined in the UI) or dynamic
type TempoQueryFiltersType string type TempoQueryFiltersType string
@ -121,6 +148,9 @@ type TraceqlFilter struct {
// The operator that connects the tag to the value, for example: =, >, !=, =~ // The operator that connects the tag to the value, for example: =, >, !=, =~
Operator *string `json:"operator,omitempty"` Operator *string `json:"operator,omitempty"`
// The scope of the filter, can either be unscoped/all scopes, resource or span
Scope *TraceqlFilterScope `json:"scope,omitempty"`
// The tag for the search filter, for example: .http.status_code, .service.name, status // The tag for the search filter, for example: .http.status_code, .service.name, status
Tag *string `json:"tag,omitempty"` Tag *string `json:"tag,omitempty"`
@ -134,8 +164,14 @@ type TraceqlFilter struct {
ValueType *string `json:"valueType,omitempty"` ValueType *string `json:"valueType,omitempty"`
} }
// 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 // The type of the filter, can either be static (pre defined in the UI) or dynamic
type TraceqlFilterType string type TraceqlFilterType string
// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user // TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user
type TraceqlSearchFilterType string type TraceqlSearchFilterType string
// TraceqlSearchScope defines model for TraceqlSearchScope.
type TraceqlSearchScope string

View File

@ -12,7 +12,15 @@ interface Props {
isTagsLoading?: boolean; isTagsLoading?: boolean;
operators: string[]; operators: string[];
} }
const validationRegex = /^\d+(?:\.\d)?\d*(?:ms|s|ns)$/;
const DurationInput = ({ filter, operators, updateFilter }: Props) => { const DurationInput = ({ filter, operators, updateFilter }: Props) => {
let invalid = false;
if (typeof filter.value === 'string') {
invalid = filter.value ? !validationRegex.test(filter.value.concat('')) : false;
}
return ( return (
<HorizontalGroup spacing={'none'}> <HorizontalGroup spacing={'none'}>
<Select <Select
@ -34,6 +42,7 @@ const DurationInput = ({ filter, operators, updateFilter }: Props) => {
}} }}
placeholder="e.g. 100ms, 1.2s" placeholder="e.g. 100ms, 1.2s"
aria-label={`select ${filter.id} value`} aria-label={`select ${filter.id} value`}
invalid={invalid}
width={18} width={18}
/> />
</HorizontalGroup> </HorizontalGroup>

View File

@ -73,10 +73,10 @@ describe('SearchField', () => {
if (select) { if (select) {
await user.click(select); await user.click(select);
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000);
const largerThanOp = await screen.findByText('>'); const largerThanOp = await screen.findByText('!=');
await user.click(largerThanOp); await user.click(largerThanOp);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '>' }); expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '!=' });
} }
}); });

View File

@ -1,19 +1,26 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { css } from '@emotion/css';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental'; import { AccessoryButton } from '@grafana/experimental';
import { FetchError, isFetchError } from '@grafana/runtime'; import { FetchError, isFetchError } from '@grafana/runtime';
import { Select, HorizontalGroup } from '@grafana/ui'; import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification'; import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification'; import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store'; import { dispatch } from '../../../../store/store';
import { TraceqlFilter } from '../dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider'; import TempoLanguageProvider from '../language_provider';
import { operators as allOperators } from '../traceql/traceql'; import { operators as allOperators, stringOperators, numberOperators } from '../traceql/traceql';
import { operatorSelectableValue } from './utils'; import { operatorSelectableValue, scopeHelper } from './utils';
const getStyles = () => ({
dropdown: css`
box-shadow: none;
`,
});
interface Props { interface Props {
filter: TraceqlFilter; filter: TraceqlFilter;
@ -23,21 +30,13 @@ interface Props {
setError: (error: FetchError) => void; setError: (error: FetchError) => void;
isTagsLoading?: boolean; isTagsLoading?: boolean;
tags: string[]; tags: string[];
operators?: string[];
} }
const SearchField = ({ const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoading, tags, setError }: Props) => {
filter, const styles = useStyles2(getStyles);
datasource,
updateFilter,
deleteFilter,
isTagsLoading,
tags,
setError,
operators,
}: Props) => {
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [isLoadingValues, setIsLoadingValues] = useState(false); const [isLoadingValues, setIsLoadingValues] = useState(false);
const [options, setOptions] = useState<Array<SelectableValue<string>>>([]); const [options, setOptions] = useState<Array<SelectableValue<string>>>([]);
const [scopedTag, setScopedTag] = useState(scopeHelper(filter) + filter.tag);
// We automatically change the operator to the regex op when users select 2 or more values // 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 // 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 // there's only one value selected, so we store the previous operator and value
@ -58,53 +57,69 @@ const SearchField = ({
setPrevValue(filter.value); setPrevValue(filter.value);
}, [filter.value]); }, [filter.value]);
const loadOptions = useCallback( useEffect(() => {
async (name: string) => { const newScopedTag = scopeHelper(filter) + filter.tag;
setIsLoadingValues(true); if (newScopedTag !== scopedTag) {
setScopedTag(newScopedTag);
}
}, [filter, scopedTag]);
try { const updateOptions = useCallback(async () => {
const options = await languageProvider.getOptionsV2(name); try {
return options; setIsLoadingValues(true);
} catch (error) { setOptions(await languageProvider.getOptionsV2(scopedTag));
if (isFetchError(error) && error?.status === 404) { } catch (error) {
setError(error); // Display message if Tempo is connected but search 404's
} else if (error instanceof Error) { if (isFetchError(error) && error?.status === 404) {
dispatch(notifyApp(createErrorNotification('Error', error))); setError(error);
} } else if (error instanceof Error) {
return []; dispatch(notifyApp(createErrorNotification('Error', error)));
} finally {
setIsLoadingValues(false);
} }
}, } finally {
[setError, languageProvider] setIsLoadingValues(false);
); }
}, [scopedTag, languageProvider, setError]);
useEffect(() => { useEffect(() => {
const fetchOptions = async () => { updateOptions();
try { }, [updateOptions]);
if (filter.tag) {
setOptions(await loadOptions(filter.tag)); const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t }));
}
} catch (error) { // If all values have type string or int/float use a focused list of operators instead of all operators
// Display message if Tempo is connected but search 404's const optionsOfFirstType = options.filter((o) => o.type === options[0]?.type);
if (isFetchError(error) && error?.status === 404) { const uniqueOptionType = options.length === optionsOfFirstType.length ? options[0]?.type : undefined;
setError(error); let operatorList = allOperators;
} else if (error instanceof Error) { switch (uniqueOptionType) {
dispatch(notifyApp(createErrorNotification('Error', error))); case 'string':
} operatorList = stringOperators;
} break;
}; case 'int':
fetchOptions(); case 'float':
}, [languageProvider, loadOptions, setError, filter.tag]); operatorList = numberOperators;
}
return ( return (
<HorizontalGroup spacing={'none'}> <HorizontalGroup spacing={'none'} width={'auto'}>
{filter.type === 'dynamic' && ( {filter.type === 'dynamic' && (
<Select <Select
className={styles.dropdown}
inputId={`${filter.id}-scope`}
options={scopeOptions}
value={filter.scope}
onChange={(v) => {
updateFilter({ ...filter, scope: v?.value });
}}
placeholder="Select scope"
aria-label={`select ${filter.id} scope`}
/>
)}
{filter.type === 'dynamic' && (
<Select
className={styles.dropdown}
inputId={`${filter.id}-tag`} inputId={`${filter.id}-tag`}
isLoading={isTagsLoading} isLoading={isTagsLoading}
options={tags.map((t) => ({ label: t, value: t }))} options={tags.map((t) => ({ label: t, value: t }))}
onOpenMenu={() => tags}
value={filter.tag} value={filter.tag}
onChange={(v) => { onChange={(v) => {
updateFilter({ ...filter, tag: v?.value }); updateFilter({ ...filter, tag: v?.value });
@ -116,8 +131,9 @@ const SearchField = ({
/> />
)} )}
<Select <Select
className={styles.dropdown}
inputId={`${filter.id}-operator`} inputId={`${filter.id}-operator`}
options={(operators || allOperators).map(operatorSelectableValue)} options={operatorList.map(operatorSelectableValue)}
value={filter.operator} value={filter.operator}
onChange={(v) => { onChange={(v) => {
updateFilter({ ...filter, operator: v?.value }); updateFilter({ ...filter, operator: v?.value });
@ -128,14 +144,10 @@ const SearchField = ({
width={8} width={8}
/> />
<Select <Select
className={styles.dropdown}
inputId={`${filter.id}-value`} inputId={`${filter.id}-value`}
isLoading={isLoadingValues} isLoading={isLoadingValues}
options={options} options={options}
onOpenMenu={() => {
if (filter.tag) {
loadOptions(filter.tag);
}
}}
value={filter.value} value={filter.value}
onChange={(val) => { onChange={(val) => {
if (Array.isArray(val)) { if (Array.isArray(val)) {

View File

@ -1,15 +1,29 @@
import React, { useEffect, useCallback } from 'react'; import { css } from '@emotion/css';
import React, { useCallback, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { AccessoryButton } from '@grafana/experimental'; import { AccessoryButton } from '@grafana/experimental';
import { FetchError } from '@grafana/runtime'; import { FetchError } from '@grafana/runtime';
import { HorizontalGroup, VerticalGroup } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { TraceqlFilter } from '../dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import SearchField from './SearchField'; import SearchField from './SearchField';
const getStyles = () => ({
vertical: css`
display: flex;
flex-direction: column;
gap: 0.25rem;
`,
horizontal: css`
display: flex;
flex-direction: row;
gap: 1rem;
`,
});
interface Props { interface Props {
updateFilter: (f: TraceqlFilter) => void; updateFilter: (f: TraceqlFilter) => void;
deleteFilter: (f: TraceqlFilter) => void; deleteFilter: (f: TraceqlFilter) => void;
@ -20,9 +34,10 @@ interface Props {
isTagsLoading: boolean; isTagsLoading: boolean;
} }
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => { const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
const styles = useStyles2(getStyles);
const generateId = () => uuidv4().slice(0, 8); const generateId = () => uuidv4().slice(0, 8);
const handleOnAdd = useCallback( const handleOnAdd = useCallback(
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=' }), () => updateFilter({ id: generateId(), type: 'dynamic', operator: '=', scope: TraceqlSearchScope.Span }),
[updateFilter] [updateFilter]
); );
@ -32,26 +47,27 @@ const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError,
} }
}, [filters, handleOnAdd]); }, [filters, handleOnAdd]);
const dynamicFilters = filters?.filter((f) => f.type === 'dynamic');
return ( return (
<HorizontalGroup spacing={'md'} align={'flex-start'}> <div className={styles.vertical}>
<VerticalGroup spacing={'xs'}> {dynamicFilters?.map((f, i) => (
{filters <div className={styles.horizontal} key={f.id}>
?.filter((f) => f.type === 'dynamic') <SearchField
.map((f) => ( filter={f}
<SearchField datasource={datasource}
filter={f} setError={setError}
key={f.id} updateFilter={updateFilter}
datasource={datasource} tags={tags}
setError={setError} isTagsLoading={isTagsLoading}
updateFilter={updateFilter} deleteFilter={deleteFilter}
tags={tags} />
isTagsLoading={isTagsLoading} {i === dynamicFilters.length - 1 && (
deleteFilter={deleteFilter} <AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
/> )}
))} </div>
</VerticalGroup> ))}
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} /> </div>
</HorizontalGroup>
); );
}; };

View File

@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import { TempoQuery } from '../types'; import { TempoQuery } from '../types';
@ -101,7 +102,8 @@ describe('TraceQLSearch', () => {
expect(nameFilter).not.toBeNull(); expect(nameFilter).not.toBeNull();
expect(nameFilter?.operator).toBe('='); expect(nameFilter?.operator).toBe('=');
expect(nameFilter?.value).toStrictEqual(['customer']); expect(nameFilter?.value).toStrictEqual(['customer']);
expect(nameFilter?.tag).toBe('.service.name'); expect(nameFilter?.tag).toBe('service.name');
expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource);
} }
}); });

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { EditorRow } from '@grafana/experimental'; import { EditorRow } from '@grafana/experimental';
@ -10,7 +10,7 @@ import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification'; import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store'; import { dispatch } from '../../../../store/store';
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
import { TraceqlFilter } from '../dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions'; import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
import { CompletionProvider } from '../traceql/autocomplete'; import { CompletionProvider } from '../traceql/autocomplete';
@ -73,8 +73,7 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
if (!tags.find((t) => t === 'status')) { if (!tags.find((t) => t === 'status')) {
tags.push('status'); tags.push('status');
} }
const tagsWithDot = tags.sort().map((t) => `.${t}`); setTags(tags);
setTags(tagsWithDot);
setIsTagsLoading(false); setIsTagsLoading(false);
} }
} catch (error) { } catch (error) {
@ -96,15 +95,15 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
findFilter('service-name') || { findFilter('service-name') || {
id: 'service-name', id: 'service-name',
type: 'static', type: 'static',
tag: '.service.name', tag: 'service.name',
operator: '=', operator: '=',
scope: TraceqlSearchScope.Resource,
} }
} }
datasource={datasource} datasource={datasource}
setError={setError} setError={setError}
updateFilter={updateFilter} updateFilter={updateFilter}
tags={[]} tags={[]}
operators={['=', '!=', '=~']}
/> />
</InlineSearchField> </InlineSearchField>
<InlineSearchField label={'Span Name'}> <InlineSearchField label={'Span Name'}>
@ -114,7 +113,6 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
setError={setError} setError={setError}
updateFilter={updateFilter} updateFilter={updateFilter}
tags={[]} tags={[]}
operators={['=', '!=', '=~']}
/> />
</InlineSearchField> </InlineSearchField>
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span"> <InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">

View File

@ -1,3 +1,5 @@
import { TraceqlSearchScope } from '../dataquery.gen';
import { generateQueryFromFilters } from './utils'; import { generateQueryFromFilters } from './utils';
describe('generateQueryFromFilters generates the correct query for', () => { describe('generateQueryFromFilters generates the correct query for', () => {
@ -20,7 +22,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
it('a field with tag, operator and tag', () => { it('a field with tag, operator and tag', () => {
expect( expect(
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }]) generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
).toBe('{footag="foovalue"}'); ).toBe('{.footag="foovalue"}');
}); });
it('a field with valueType as integer', () => { it('a field with valueType as integer', () => {
@ -28,7 +30,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
generateQueryFromFilters([ generateQueryFromFilters([
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }, { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
]) ])
).toBe('{footag>1234}'); ).toBe('{.footag>1234}');
}); });
it('two fields with everything filled in', () => { it('two fields with everything filled in', () => {
expect( expect(
@ -36,7 +38,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' }, { id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
]) ])
).toBe('{footag>=1234 && bartag="barvalue"}'); ).toBe('{.footag>=1234 && .bartag="barvalue"}');
}); });
it('two fields but one is missing a value', () => { it('two fields but one is missing a value', () => {
expect( expect(
@ -44,7 +46,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, { id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' }, { id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
]) ])
).toBe('{footag>=1234}'); ).toBe('{.footag>=1234}');
}); });
it('two fields but one is missing a value and the other a tag', () => { it('two fields but one is missing a value and the other a tag', () => {
expect( expect(
@ -54,4 +56,49 @@ describe('generateQueryFromFilters generates the correct query for', () => {
]) ])
).toBe('{}'); ).toBe('{}');
}); });
it('scope is unscoped', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
type: 'static',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Unscoped,
valueType: 'integer',
},
])
).toBe('{.footag>=1234}');
});
it('scope is span', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
type: 'static',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Span,
valueType: 'integer',
},
])
).toBe('{span.footag>=1234}');
});
it('scope is resource', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
type: 'static',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Resource,
valueType: 'integer',
},
])
).toBe('{resource.footag>=1234}');
});
}); });

View File

@ -1,11 +1,12 @@
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { TraceqlFilter } from '../dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { CompletionProvider } from '../traceql/autocomplete';
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => { export const generateQueryFromFilters = (filters: TraceqlFilter[]) => {
return `{${filters return `{${filters
.filter((f) => f.tag && f.operator && f.value?.length) .filter((f) => f.tag && f.operator && f.value?.length)
.map((f) => `${f.tag}${f.operator}${valueHelper(f)}`) .map((f) => `${scopeHelper(f)}${f.tag}${f.operator}${valueHelper(f)}`)
.join(' && ')}}`; .join(' && ')}}`;
}; };
@ -18,6 +19,15 @@ const valueHelper = (f: TraceqlFilter) => {
} }
return f.value; return f.value;
}; };
export const scopeHelper = (f: TraceqlFilter) => {
// Intrinsic fields don't have a scope
if (CompletionProvider.intrinsics.find((t) => t === f.tag)) {
return '';
}
return (
(f.scope === TraceqlSearchScope.Resource || f.scope === TraceqlSearchScope.Span ? f.scope?.toLowerCase() : '') + '.'
);
};
export function replaceAt<T>(array: T[], index: number, value: T) { export function replaceAt<T>(array: T[], index: number, value: T) {
const ret = array.slice(0); const ret = array.slice(0);

View File

@ -54,7 +54,8 @@ composableKinds: DataQuery: {
#TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type") #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 // static fields are pre-set in the UI, dynamic fields are added by the user
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type") #TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
#TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum")
#TraceqlFilter: { #TraceqlFilter: {
// Uniquely identify the filter, will not be used in the query generation // Uniquely identify the filter, will not be used in the query generation
id: string id: string
@ -68,6 +69,8 @@ composableKinds: DataQuery: {
value?: string | [...string] value?: string | [...string]
// The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query // The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query
valueType?: string valueType?: string
// The scope of the filter, can either be unscoped/all scopes, resource or span
scope?: #TraceqlSearchScope
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
}, },
] ]

View File

@ -62,6 +62,12 @@ export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceM
*/ */
export type TraceqlSearchFilterType = ('static' | 'dynamic'); export type TraceqlSearchFilterType = ('static' | 'dynamic');
export enum TraceqlSearchScope {
Resource = 'resource',
Span = 'span',
Unscoped = 'unscoped',
}
export interface TraceqlFilter { export interface TraceqlFilter {
/** /**
* Uniquely identify the filter, will not be used in the query generation * Uniquely identify the filter, will not be used in the query generation
@ -71,6 +77,10 @@ export interface TraceqlFilter {
* The operator that connects the tag to the value, for example: =, >, !=, =~ * The operator that connects the tag to the value, for example: =, >, !=, =~
*/ */
operator?: string; operator?: string;
/**
* The scope of the filter, can either be unscoped/all scopes, resource or span
*/
scope?: TraceqlSearchScope;
/** /**
* The tag for the search filter, for example: .http.status_code, .service.name, status * The tag for the search filter, for example: .http.status_code, .service.name, status
*/ */

View File

@ -23,6 +23,8 @@ export const languageConfiguration = {
}; };
export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~']; export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~'];
export const stringOperators = ['=', '!=', '=~'];
export const numberOperators = ['=', '!=', '>', '<', '>=', '<='];
const intrinsics = ['duration', 'name', 'status', 'parent']; const intrinsics = ['duration', 'name', 'status', 'parent'];