Tempo: Support new traceql intrinsics (#94240)

* Tidy and update types

* Tidy up comments

* More tidy up

* Simplify logic and reduce repitition in autocomplete

* Simplify logic

* Add and support new intrinsics

* Only retrieve intrinsics from language provider

* Move generateQueryFromFilters to languageProvider
This commit is contained in:
Joey 2024-10-16 16:08:02 +01:00 committed by GitHub
parent 50a635bc7e
commit bd321216db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 357 additions and 341 deletions

View File

@ -187,6 +187,7 @@ describe('SearchField', () => {
type: 'keyword', type: 'keyword',
}, },
]), ]),
getIntrinsics: jest.fn().mockReturnValue(['duration']),
} as unknown as TempoLanguageProvider; } as unknown as TempoLanguageProvider;
const { container } = renderSearchField(jest.fn(), filter, [], false, lp); const { container } = renderSearchField(jest.fn(), filter, [], false, lp);
@ -235,6 +236,7 @@ describe('SearchField', () => {
type: 'int', type: 'int',
}, },
]), ]),
getIntrinsics: jest.fn().mockReturnValue(['duration']),
} as unknown as TempoLanguageProvider; } as unknown as TempoLanguageProvider;
const { container } = renderSearchField(jest.fn(), filter, [], false, lp); const { container } = renderSearchField(jest.fn(), filter, [], false, lp);
@ -280,6 +282,7 @@ const renderSearchField = (
type: 'string', type: 'string',
}, },
]), ]),
getIntrinsics: jest.fn().mockReturnValue(['duration']),
} as unknown as TempoLanguageProvider); } as unknown as TempoLanguageProvider);
const datasource: TempoDatasource = { const datasource: TempoDatasource = {

View File

@ -53,7 +53,10 @@ const SearchField = ({
}: Props) => { }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [alertText, setAlertText] = useState<string>(); const [alertText, setAlertText] = useState<string>();
const scopedTag = useMemo(() => filterScopedTag(filter), [filter]); const scopedTag = useMemo(
() => filterScopedTag(filter, datasource.languageProvider),
[datasource.languageProvider, filter]
);
// 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

View File

@ -43,7 +43,7 @@ describe('TagsInput', () => {
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000);
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('foo')).toBeInTheDocument(); expect(screen.getByText('rootServiceName')).toBeInTheDocument();
expect(screen.getByText('bar')).toBeInTheDocument(); expect(screen.getByText('bar')).toBeInTheDocument();
}); });
}); });

View File

@ -69,7 +69,7 @@ const TagsInput = ({
const getTags = (f: TraceqlFilter) => { const getTags = (f: TraceqlFilter) => {
const tags = datasource.languageProvider.getTags(f.scope); const tags = datasource.languageProvider.getTags(f.scope);
return getFilteredTags(tags, staticTags); return getFilteredTags(tags, datasource.languageProvider, staticTags);
}; };
const validInput = (f: TraceqlFilter) => { const validInput = (f: TraceqlFilter) => {

View File

@ -65,7 +65,10 @@ describe('TraceQLSearch', () => {
}, },
} as TempoDatasource; } as TempoDatasource;
datasource.isStreamingSearchEnabled = () => false; datasource.isStreamingSearchEnabled = () => false;
datasource.languageProvider = new TempoLanguageProvider(datasource); const lp = new TempoLanguageProvider(datasource);
lp.getIntrinsics = () => ['duration'];
lp.generateQueryFromFilters = () => '{}';
datasource.languageProvider = lp;
let query: TempoQuery = { let query: TempoQuery = {
refId: 'A', refId: 'A',
queryType: 'traceqlSearch', queryType: 'traceqlSearch',
@ -218,7 +221,10 @@ describe('TraceQLSearch', () => {
}, },
} as TempoDatasource; } as TempoDatasource;
datasource.isStreamingSearchEnabled = () => false; datasource.isStreamingSearchEnabled = () => false;
datasource.languageProvider = new TempoLanguageProvider(datasource); const lp = new TempoLanguageProvider(datasource);
lp.getIntrinsics = () => ['duration'];
lp.generateQueryFromFilters = () => '{}';
datasource.languageProvider = lp;
await act(async () => { await act(async () => {
const { container } = render( const { container } = render(
<TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} /> <TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} />

View File

@ -18,7 +18,7 @@ import { GroupByField } from './GroupByField';
import InlineSearchField from './InlineSearchField'; import InlineSearchField from './InlineSearchField';
import SearchField from './SearchField'; import SearchField from './SearchField';
import TagsInput from './TagsInput'; import TagsInput from './TagsInput';
import { filterScopedTag, filterTitle, generateQueryFromFilters, interpolateFilters, replaceAt } from './utils'; import { filterScopedTag, filterTitle, interpolateFilters, replaceAt } from './utils';
interface Props { interface Props {
datasource: TempoDatasource; datasource: TempoDatasource;
@ -64,8 +64,8 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
const templateVariables = getTemplateSrv().getVariables(); const templateVariables = getTemplateSrv().getVariables();
useEffect(() => { useEffect(() => {
setTraceQlQuery(generateQueryFromFilters(interpolateFilters(query.filters || []))); setTraceQlQuery(datasource.languageProvider.generateQueryFromFilters(interpolateFilters(query.filters || [])));
}, [query, templateVariables]); }, [datasource.languageProvider, query, templateVariables]);
const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]); const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]);
@ -99,6 +99,10 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
const staticTags = datasource.search?.filters?.map((f) => f.tag) || []; const staticTags = datasource.search?.filters?.map((f) => f.tag) || [];
staticTags.push('duration'); staticTags.push('duration');
staticTags.push('traceDuration'); staticTags.push('traceDuration');
staticTags.push('span:duration');
staticTags.push('trace:duration');
staticTags.push('status');
staticTags.push('span:status');
// Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration // Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration
// The duration and status fields are a special case since its selector is hard-coded // The duration and status fields are a special case since its selector is hard-coded
@ -118,9 +122,10 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
f.tag && ( f.tag && (
<InlineSearchField <InlineSearchField
key={f.id} key={f.id}
label={filterTitle(f)} label={filterTitle(f, datasource.languageProvider)}
tooltip={`Filter your search by ${filterScopedTag( tooltip={`Filter your search by ${filterScopedTag(
f f,
datasource.languageProvider
)}. To modify the default filters shown for search visit the Tempo datasource configuration page.`} )}. To modify the default filters shown for search visit the Tempo datasource configuration page.`}
> >
<SearchField <SearchField
@ -242,7 +247,7 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
}); });
onClearResults(); onClearResults();
const traceQlQuery = generateQueryFromFilters(query.filters || []); const traceQlQuery = datasource.languageProvider.generateQueryFromFilters(query.filters || []);
onChange({ onChange({
...query, ...query,
query: traceQlQuery, query: traceQlQuery,

View File

@ -1,242 +1,84 @@
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { TraceqlSearchScope } from '../dataquery.gen'; import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { intrinsicsV1 } from '../traceql/traceql';
import { import { getUnscopedTags, getFilteredTags, getAllTags, getTagsByScope, generateQueryFromAdHocFilters } from './utils';
generateQueryFromFilters,
getUnscopedTags,
getFilteredTags,
getAllTags,
getTagsByScope,
generateQueryFromAdHocFilters,
} from './utils';
describe('generateQueryFromFilters generates the correct query for', () => { const datasource: TempoDatasource = {
it('an empty array', () => { search: {
expect(generateQueryFromFilters([])).toBe('{}'); filters: [],
});
it('a field without value', () => {
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', operator: '=' }])).toBe('{}');
});
it('a field with value but without tag', () => {
expect(generateQueryFromFilters([{ id: 'foo', value: 'foovalue', operator: '=' }])).toBe('{}');
});
it('a field with value and tag but without operator', () => {
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}');
});
describe('generates correct query for duration when duration type', () => {
it('not set', () => {
expect(
generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
])
).toBe('{duration>100ms}');
});
it('set to span', () => {
expect(
generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
{ id: 'duration-type', value: 'span' },
])
).toBe('{duration>100ms}');
});
it('set to trace', () => {
expect(
generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
{ id: 'duration-type', value: 'trace' },
])
).toBe('{traceDuration>100ms}');
});
});
it('a field with tag, operator and tag', () => {
expect(generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe(
'{.footag=foovalue}'
);
expect(
generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=', valueType: 'string' }])
).toBe('{.footag="foovalue"}');
});
it('a field with valueType as integer', () => {
expect(
generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }])
).toBe('{.footag>1234}');
});
it('two fields with everything filled in', () => {
expect(
generateQueryFromFilters([
{ 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', 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', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
])
).toBe('{}');
});
it('scope is unscoped', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Unscoped,
valueType: 'integer',
}, },
]) } as unknown as TempoDatasource;
).toBe('{.footag>=1234}'); const lp = new TempoLanguageProvider(datasource);
});
it('scope is span', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Span,
valueType: 'integer',
},
])
).toBe('{span.footag>=1234}');
});
it('scope is resource', () => {
expect(
generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Resource,
valueType: 'integer',
},
])
).toBe('{resource.footag>=1234}');
});
});
describe('generateQueryFromAdHocFilters generates the correct query for', () => { describe('generateQueryFromAdHocFilters generates the correct query for', () => {
it('an empty array', () => { it('an empty array', () => {
expect(generateQueryFromAdHocFilters([])).toBe('{}'); expect(generateQueryFromAdHocFilters([], lp)).toBe('{}');
}); });
it('a filter with values', () => { it('a filter with values', () => {
expect(generateQueryFromAdHocFilters([{ key: 'footag', operator: '=', value: 'foovalue' }])).toBe( expect(generateQueryFromAdHocFilters([{ key: 'footag', operator: '=', value: 'foovalue' }], lp)).toBe(
'{footag="foovalue"}' '{footag="foovalue"}'
); );
}); });
it('two filters with values', () => { it('two filters with values', () => {
expect( expect(
generateQueryFromAdHocFilters([ generateQueryFromAdHocFilters(
[
{ key: 'footag', operator: '=', value: 'foovalue' }, { key: 'footag', operator: '=', value: 'foovalue' },
{ key: 'bartag', operator: '=', value: '0' }, { key: 'bartag', operator: '=', value: '0' },
]) ],
lp
)
).toBe('{footag="foovalue" && bartag=0}'); ).toBe('{footag="foovalue" && bartag=0}');
}); });
it('a filter with intrinsic values', () => { it('a filter with intrinsic values', () => {
expect(generateQueryFromAdHocFilters([{ key: 'kind', operator: '=', value: 'server' }])).toBe('{kind=server}'); expect(generateQueryFromAdHocFilters([{ key: 'kind', operator: '=', value: 'server' }], lp)).toBe('{kind=server}');
}); });
}); });
describe('gets correct tags', () => { describe('gets correct tags', () => {
const datasource: TempoDatasource = {
search: {
filters: [],
},
} as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource);
it('for filtered tags when no tags supplied', () => { it('for filtered tags when no tags supplied', () => {
const tags = getFilteredTags(emptyTags, []); const tags = getFilteredTags(emptyTags, lp, []);
expect(tags).toEqual([ expect(tags).toEqual(intrinsicsV1);
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
]);
}); });
it('for filtered tags when API v1 tags supplied', () => { it('for filtered tags when API v1 tags supplied', () => {
const tags = getFilteredTags(v1Tags, []); const tags = getFilteredTags(v1Tags, lp, []);
expect(tags).toEqual([ expect(tags).toEqual(intrinsicsV1.concat(['bar', 'foo']));
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'bar',
'foo',
]);
}); });
it('for filtered tags when API v1 tags supplied with tags to filter out', () => { it('for filtered tags when API v1 tags supplied with tags to filter out', () => {
const tags = getFilteredTags(v1Tags, ['duration']); const tags = getFilteredTags(v1Tags, lp, ['duration']);
expect(tags).toEqual([ expect(tags).toEqual(intrinsicsV1.filter((x) => x !== 'duration').concat(['bar', 'foo']));
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'bar',
'foo',
]);
}); });
it('for filtered tags when API v2 tags supplied', () => { it('for filtered tags when API v2 tags supplied', () => {
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), []); const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), lp, []);
expect(tags).toEqual([ expect(tags).toEqual(intrinsicsV1.concat(['cluster', 'container', 'db']));
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'cluster',
'container',
'db',
]);
}); });
it('for filtered tags when API v2 tags supplied with tags to filter out', () => { it('for filtered tags when API v2 tags supplied with tags to filter out', () => {
const tags = getFilteredTags(getUnscopedTags(v2Tags), ['duration', 'cluster']); const tags = getFilteredTags(getUnscopedTags(v2Tags), lp, ['duration', 'cluster']);
expect(tags).toEqual([ expect(tags).toEqual(intrinsicsV1.filter((x) => x !== 'duration').concat(['container', 'db']));
'kind', });
'name',
'rootName', it('for filtered tags when API v2 tags set', () => {
'rootServiceName', lp.setV2Tags(v2Tags);
'status', const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), lp, []);
'statusMessage', expect(tags).toEqual(testIntrinsics.concat(['cluster', 'container', 'db']));
'traceDuration',
'container',
'db',
]);
}); });
it('for unscoped tags', () => { it('for unscoped tags', () => {
@ -261,6 +103,7 @@ describe('gets correct tags', () => {
}); });
export const emptyTags = []; export const emptyTags = [];
export const testIntrinsics = ['duration', 'kind', 'name', 'status'];
export const v1Tags = ['bar', 'foo']; export const v1Tags = ['bar', 'foo'];
export const v2Tags = [ export const v2Tags = [
{ {
@ -273,6 +116,6 @@ export const v2Tags = [
}, },
{ {
name: 'intrinsic', name: 'intrinsic',
tags: ['duration', 'kind', 'name', 'status'], tags: testIntrinsics,
}, },
]; ];

View File

@ -5,7 +5,7 @@ import { getTemplateSrv } from '@grafana/runtime';
import { VariableFormatID } from '@grafana/schema'; import { VariableFormatID } from '@grafana/schema';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { intrinsics } from '../traceql/traceql'; import TempoLanguageProvider from '../language_provider';
import { Scope } from '../types'; import { Scope } from '../types';
export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: ScopedVars) => { export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: ScopedVars) => {
@ -28,18 +28,7 @@ export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: Scoped
return interpolatedFilters; return interpolatedFilters;
}; };
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => { export const valueHelper = (f: TraceqlFilter) => {
if (!filters) {
return '';
}
return `{${filters
.filter((f) => f.tag && f.operator && f.value?.length)
.map((f) => `${scopeHelper(f)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`)
.join(' && ')}}`;
};
const valueHelper = (f: TraceqlFilter) => {
if (Array.isArray(f.value) && f.value.length > 1) { if (Array.isArray(f.value) && f.value.length > 1) {
return `"${f.value.join('|')}"`; return `"${f.value.join('|')}"`;
} }
@ -49,9 +38,9 @@ const valueHelper = (f: TraceqlFilter) => {
return f.value; return f.value;
}; };
const scopeHelper = (f: TraceqlFilter) => { export const scopeHelper = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
// Intrinsic fields don't have a scope // Intrinsic fields don't have a scope
if (intrinsics.find((t) => t === f.tag)) { if (lp.getIntrinsics().find((t) => t === f.tag)) {
return ''; return '';
} }
return ( return (
@ -59,7 +48,7 @@ const scopeHelper = (f: TraceqlFilter) => {
); );
}; };
const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => { export const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => {
if (f.tag === 'duration') { if (f.tag === 'duration') {
const durationType = filters.find((f) => f.id === 'duration-type'); const durationType = filters.find((f) => f.id === 'duration-type');
if (durationType) { if (durationType) {
@ -70,15 +59,15 @@ const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => {
return f.tag; return f.tag;
}; };
export const generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[]) => { export const generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[], lp: TempoLanguageProvider) => {
return `{${filters return `{${filters
.filter((f) => f.key && f.operator && f.value) .filter((f) => f.key && f.operator && f.value)
.map((f) => `${f.key}${f.operator}${adHocValueHelper(f)}`) .map((f) => `${f.key}${f.operator}${adHocValueHelper(f, lp)}`)
.join(' && ')}}`; .join(' && ')}}`;
}; };
const adHocValueHelper = (f: AdHocVariableFilter) => { const adHocValueHelper = (f: AdHocVariableFilter, lp: TempoLanguageProvider) => {
if (intrinsics.find((t) => t === f.key)) { if (lp.getIntrinsics().find((t) => t === f.key)) {
return f.value; return f.value;
} }
if (parseInt(f.value, 10).toString() === f.value) { if (parseInt(f.value, 10).toString() === f.value) {
@ -91,11 +80,11 @@ export const getTagWithoutScope = (tag: string) => {
return tag.replace(/^(event|link|resource|span)\./, ''); return tag.replace(/^(event|link|resource|span)\./, '');
}; };
export const filterScopedTag = (f: TraceqlFilter) => { export const filterScopedTag = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
return scopeHelper(f) + f.tag; return scopeHelper(f, lp) + f.tag;
}; };
export const filterTitle = (f: TraceqlFilter) => { export const filterTitle = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
// Special case for the intrinsic "name" since a label called "Name" isn't explicit // Special case for the intrinsic "name" since a label called "Name" isn't explicit
if (f.tag === 'name') { if (f.tag === 'name') {
return 'Span Name'; return 'Span Name';
@ -104,16 +93,34 @@ export const filterTitle = (f: TraceqlFilter) => {
if (f.tag === 'service.name' && f.scope === TraceqlSearchScope.Resource) { if (f.tag === 'service.name' && f.scope === TraceqlSearchScope.Resource) {
return 'Service Name'; return 'Service Name';
} }
return startCase(filterScopedTag(f)); return startCase(filterScopedTag(f, lp));
}; };
export const getFilteredTags = (tags: string[], staticTags: Array<string | undefined>) => { export const getFilteredTags = (
return [...intrinsics, ...tags].filter((t) => !staticTags.includes(t)); tags: string[],
languageProvider: TempoLanguageProvider,
staticTags: Array<string | undefined>
) => {
return [...languageProvider.getIntrinsics(), ...tags].filter((t) => !staticTags.includes(t));
}; };
export const getUnscopedTags = (scopes: Scope[]) => { export const getUnscopedTags = (scopes: Scope[]) => {
return uniq( return uniq(
scopes.map((scope: Scope) => (scope.name && scope.name !== 'intrinsic' && scope.tags ? scope.tags : [])).flat() scopes
.map((scope: Scope) =>
scope.name && scope.name !== TraceqlSearchScope.Intrinsic && scope.tags ? scope.tags : []
)
.flat()
);
};
export const getIntrinsicTags = (scopes: Scope[]) => {
return uniq(
scopes
.map((scope: Scope) =>
scope.name && scope.name === TraceqlSearchScope.Intrinsic && scope.tags ? scope.tags : []
)
.flat()
); );
}; };

View File

@ -34,12 +34,7 @@ import {
} from '@grafana/runtime'; } from '@grafana/runtime';
import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema'; import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema';
import { import { generateQueryFromAdHocFilters, getTagWithoutScope, interpolateFilters } from './SearchTraceQLEditor/utils';
generateQueryFromAdHocFilters,
generateQueryFromFilters,
getTagWithoutScope,
interpolateFilters,
} from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor'; import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types'; import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
import { SearchTableType, TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; import { SearchTableType, TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
@ -228,7 +223,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
// Allows to retrieve the list of tag values for ad-hoc filters // Allows to retrieve the list of tag values for ad-hoc filters
getTagValues(options: DataSourceGetTagValuesOptions<TempoQuery>): Promise<Array<{ text: string }>> { getTagValues(options: DataSourceGetTagValuesOptions<TempoQuery>): Promise<Array<{ text: string }>> {
const query = generateQueryFromAdHocFilters(options.filters); const query = generateQueryFromAdHocFilters(options.filters, this.languageProvider);
return this.tagValuesQuery(options.key, query); return this.tagValuesQuery(options.key, query);
} }
@ -382,9 +377,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
const target = targets.traceqlSearch.find((t) => this.hasGroupBy(t)); const target = targets.traceqlSearch.find((t) => this.hasGroupBy(t));
if (target) { if (target) {
const appliedQuery = this.applyVariables(target, options.scopedVars); const appliedQuery = this.applyVariables(target, options.scopedVars);
subQueries.push( const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
this.handleMetricsSummaryQuery(appliedQuery, generateQueryFromFilters(appliedQuery.filters), options) subQueries.push(this.handleMetricsSummaryQuery(appliedQuery, queryFromFilters, options));
);
} }
} }
@ -393,22 +387,22 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
: targets.traceqlSearch; : targets.traceqlSearch;
if (traceqlSearchTargets.length > 0) { if (traceqlSearchTargets.length > 0) {
const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars); const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars);
const queryValueFromFilters = generateQueryFromFilters(appliedQuery.filters); const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
reportInteraction('grafana_traces_traceql_search_queried', { reportInteraction('grafana_traces_traceql_search_queried', {
datasourceType: 'tempo', datasourceType: 'tempo',
app: options.app ?? '', app: options.app ?? '',
grafana_version: config.buildInfo.version, grafana_version: config.buildInfo.version,
query: queryValueFromFilters ?? '', query: queryFromFilters ?? '',
streaming: this.isStreamingSearchEnabled(), streaming: this.isStreamingSearchEnabled(),
}); });
if (this.isStreamingSearchEnabled()) { if (this.isStreamingSearchEnabled()) {
subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryValueFromFilters)); subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryFromFilters));
} else { } else {
subQueries.push( subQueries.push(
this._request('/api/search', { this._request('/api/search', {
q: queryValueFromFilters, q: queryFromFilters,
limit: options.targets[0].limit ?? DEFAULT_LIMIT, limit: options.targets[0].limit ?? DEFAULT_LIMIT,
spss: options.targets[0].spss ?? DEFAULT_SPSS, spss: options.targets[0].spss ?? DEFAULT_SPSS,
start: options.range.from.unix(), start: options.range.from.unix(),
@ -831,7 +825,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
} }
const appliedQuery = this.applyVariables(query, {}); const appliedQuery = this.applyVariables(query, {});
return generateQueryFromFilters(appliedQuery.filters); return this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
} }
} }

View File

@ -109,6 +109,138 @@ describe('Language_provider', () => {
}); });
}); });
describe('generateQueryFromFilters generates the correct query for', () => {
let lp: TempoLanguageProvider;
beforeEach(() => {
lp = setup(v1Tags);
});
it('an empty array', () => {
expect(lp.generateQueryFromFilters([])).toBe('{}');
});
it('a field without value', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', operator: '=' }])).toBe('{}');
});
it('a field with value but without tag', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', value: 'foovalue', operator: '=' }])).toBe('{}');
});
it('a field with value and tag but without operator', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}');
});
describe('generates correct query for duration when duration type', () => {
it('not set', () => {
expect(
lp.generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
])
).toBe('{duration>100ms}');
});
it('set to span', () => {
expect(
lp.generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
{ id: 'duration-type', value: 'span' },
])
).toBe('{duration>100ms}');
});
it('set to trace', () => {
expect(
lp.generateQueryFromFilters([
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
{ id: 'duration-type', value: 'trace' },
])
).toBe('{traceDuration>100ms}');
});
});
it('a field with tag, operator and tag', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe(
'{.footag=foovalue}'
);
expect(
lp.generateQueryFromFilters([
{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=', valueType: 'string' },
])
).toBe('{.footag="foovalue"}');
});
it('a field with valueType as integer', () => {
expect(
lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }])
).toBe('{.footag>1234}');
});
it('two fields with everything filled in', () => {
expect(
lp.generateQueryFromFilters([
{ 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(
lp.generateQueryFromFilters([
{ 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(
lp.generateQueryFromFilters([
{ id: 'foo', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
])
).toBe('{}');
});
it('scope is unscoped', () => {
expect(
lp.generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Unscoped,
valueType: 'integer',
},
])
).toBe('{.footag>=1234}');
});
it('scope is span', () => {
expect(
lp.generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Span,
valueType: 'integer',
},
])
).toBe('{span.footag>=1234}');
});
it('scope is resource', () => {
expect(
lp.generateQueryFromFilters([
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Resource,
valueType: 'integer',
},
])
).toBe('{resource.footag>=1234}');
});
});
const setup = (tagsV1?: string[], tagsV2?: Scope[]) => { const setup = (tagsV1?: string[], tagsV2?: Scope[]) => {
const datasource: TempoDatasource = { const datasource: TempoDatasource = {
search: { search: {

View File

@ -2,9 +2,18 @@ import { LanguageProvider, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { VariableFormatID } from '@grafana/schema'; import { VariableFormatID } from '@grafana/schema';
import { getAllTags, getTagsByScope, getUnscopedTags } from './SearchTraceQLEditor/utils'; import {
import { TraceqlSearchScope } from './dataquery.gen'; getAllTags,
getIntrinsicTags,
getTagsByScope,
getUnscopedTags,
scopeHelper,
tagHelper,
valueHelper,
} from './SearchTraceQLEditor/utils';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import { TempoDatasource } from './datasource'; import { TempoDatasource } from './datasource';
import { intrinsicsV1 } from './traceql/traceql';
import { Scope } from './types'; import { Scope } from './types';
export default class TempoLanguageProvider extends LanguageProvider { export default class TempoLanguageProvider extends LanguageProvider {
@ -56,6 +65,13 @@ export default class TempoLanguageProvider extends LanguageProvider {
this.tagsV2 = tags; this.tagsV2 = tags;
}; };
getIntrinsics = () => {
if (this.tagsV2) {
return getIntrinsicTags(this.tagsV2);
}
return intrinsicsV1;
};
getTags = (scope?: TraceqlSearchScope) => { getTags = (scope?: TraceqlSearchScope) => {
if (this.tagsV2 && scope) { if (this.tagsV2 && scope) {
if (scope === TraceqlSearchScope.Unscoped) { if (scope === TraceqlSearchScope.Unscoped) {
@ -164,4 +180,15 @@ export default class TempoLanguageProvider extends LanguageProvider {
// Reference: https://stackoverflow.com/a/37456192 // Reference: https://stackoverflow.com/a/37456192
return encodeURIComponent(encodeURIComponent(tag)); return encodeURIComponent(encodeURIComponent(tag));
}; };
generateQueryFromFilters(filters: TraceqlFilter[]) {
if (!filters) {
return '';
}
return `{${filters
.filter((f) => f.tag && f.operator && f.value?.length)
.map((f) => `${scopeHelper(f, this)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`)
.join(' && ')}}`;
}
} }

View File

@ -6,7 +6,6 @@ import { GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { Button, InlineLabel, useStyles2 } from '@grafana/ui'; import { Button, InlineLabel, useStyles2 } from '@grafana/ui';
import { generateQueryFromFilters } from '../SearchTraceQLEditor/utils';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import { defaultQuery, MyDataSourceOptions, TempoQuery } from '../types'; import { defaultQuery, MyDataSourceOptions, TempoQuery } from '../types';
@ -23,7 +22,7 @@ export function QueryEditor(props: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const query = defaults(props.query, defaultQuery); const query = defaults(props.query, defaultQuery);
const [showCopyFromSearchButton, setShowCopyFromSearchButton] = useState(() => { const [showCopyFromSearchButton, setShowCopyFromSearchButton] = useState(() => {
const genQuery = generateQueryFromFilters(query.filters || []); const genQuery = props.datasource.languageProvider.generateQueryFromFilters(query.filters || []);
return genQuery === query.query || genQuery === '{}'; return genQuery === query.query || genQuery === '{}';
}); });
@ -51,7 +50,7 @@ export function QueryEditor(props: Props) {
props.onClearResults(); props.onClearResults();
props.onChange({ props.onChange({
...query, ...query,
query: generateQueryFromFilters(query.filters || []), query: props.datasource.languageProvider.generateQueryFromFilters(query.filters || []),
}); });
setShowCopyFromSearchButton(true); setShowCopyFromSearchButton(true);
}} }}

View File

@ -9,7 +9,7 @@ import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import { TempoQuery } from '../types'; import { TempoQuery } from '../types';
import { CompletionProvider, CompletionType } from './autocomplete'; import { CompletionProvider, CompletionItemType } from './autocomplete';
import { getErrorNodes, setMarkers } from './highlighting'; import { getErrorNodes, setMarkers } from './highlighting';
import { languageDefinition } from './traceql'; import { languageDefinition } from './traceql';
@ -109,7 +109,7 @@ export function TraceQLEditor(props: Props) {
errorNodes.filter((errorNode) => !(errorNode.from <= cursorPosition && cursorPosition <= errorNode.to)) errorNodes.filter((errorNode) => !(errorNode.from <= cursorPosition && cursorPosition <= errorNode.to))
); );
// Later on, show all errors // Show all errors after a short delay, to avoid flickering
errorTimeoutId.current = window.setTimeout(() => { errorTimeoutId.current = window.setTimeout(() => {
setMarkers(monaco, model, errorNodes); setMarkers(monaco, model, errorNodes);
}, 500); }, 500);
@ -126,7 +126,7 @@ function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, mona
{ {
range: new monaco.Range(1, 1, 1, 1), range: new monaco.Range(1, 1, 1, 1),
options: { options: {
className: styles.placeholder, // The placeholder text is in styles.placeholder className: styles.placeholder,
isWholeLine: true, isWholeLine: true,
}, },
}, },
@ -163,7 +163,7 @@ function setupActions(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco:
} }
function setupRegisterInteractionCommand(editor: monacoTypes.editor.IStandaloneCodeEditor): string | null { function setupRegisterInteractionCommand(editor: monacoTypes.editor.IStandaloneCodeEditor): string | null {
return editor.addCommand(0, function (_, label, type: CompletionType) { return editor.addCommand(0, function (_, label, type: CompletionItemType) {
const properties: Record<string, unknown> = { datasourceType: 'tempo', type }; const properties: Record<string, unknown> = { datasourceType: 'tempo', type };
// Filter out the label for TAG_VALUE completions to avoid potentially exposing sensitive data // Filter out the label for TAG_VALUE completions to avoid potentially exposing sensitive data
if (type !== 'TAG_VALUE') { if (type !== 'TAG_VALUE') {

View File

@ -1,13 +1,13 @@
import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data'; import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data';
import { monacoTypes } from '@grafana/ui'; import { monacoTypes } from '@grafana/ui';
import { emptyTags, v1Tags, v2Tags } from '../SearchTraceQLEditor/utils.test'; import { emptyTags, testIntrinsics, v1Tags, v2Tags } from '../SearchTraceQLEditor/utils.test';
import { TempoDatasource } from '../datasource'; import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider'; import TempoLanguageProvider from '../language_provider';
import { Scope, TempoJsonData } from '../types'; import { Scope, TempoJsonData } from '../types';
import { CompletionProvider } from './autocomplete'; import { CompletionProvider } from './autocomplete';
import { intrinsics, scopes } from './traceql'; import { intrinsicsV1, scopes } from './traceql';
const emptyPosition = {} as monacoTypes.Position; const emptyPosition = {} as monacoTypes.Position;
@ -21,7 +21,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition); const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })), ...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })), ...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'bar', insertText: '.bar' }), expect.objectContaining({ label: 'bar', insertText: '.bar' }),
expect.objectContaining({ label: 'foo', insertText: '.foo' }), expect.objectContaining({ label: 'foo', insertText: '.foo' }),
expect.objectContaining({ label: 'status', insertText: '.status' }), expect.objectContaining({ label: 'status', insertText: '.status' }),
@ -33,7 +33,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition); const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })), ...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })), ...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'cluster', insertText: '.cluster' }), expect.objectContaining({ label: 'cluster', insertText: '.cluster' }),
expect.objectContaining({ label: 'container', insertText: '.container' }), expect.objectContaining({ label: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }), expect.objectContaining({ label: 'db', insertText: '.db' }),
@ -138,7 +138,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition); const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), ...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
expect.objectContaining({ label: 'bar', insertText: '{ .bar' }), expect.objectContaining({ label: 'bar', insertText: '{ .bar' }),
expect.objectContaining({ label: 'foo', insertText: '{ .foo' }), expect.objectContaining({ label: 'foo', insertText: '{ .foo' }),
expect.objectContaining({ label: 'status', insertText: '{ .status' }), expect.objectContaining({ label: 'status', insertText: '{ .status' }),
@ -150,7 +150,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition); const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })), ...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
expect.objectContaining({ label: 'cluster', insertText: '{ .cluster' }), expect.objectContaining({ label: 'cluster', insertText: '{ .cluster' }),
expect.objectContaining({ label: 'container', insertText: '{ .container' }), expect.objectContaining({ label: 'container', insertText: '{ .container' }),
expect.objectContaining({ label: 'db', insertText: '{ .db' }), expect.objectContaining({ label: 'db', insertText: '{ .db' }),
@ -229,7 +229,7 @@ describe('CompletionProvider', () => {
expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation }) expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation })
), ),
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })), ...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })), ...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'cluster', insertText: '.cluster' }), expect.objectContaining({ label: 'cluster', insertText: '.cluster' }),
expect.objectContaining({ label: 'container', insertText: '.container' }), expect.objectContaining({ label: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }), expect.objectContaining({ label: 'db', insertText: '.db' }),
@ -361,7 +361,7 @@ describe('CompletionProvider', () => {
const { provider, model } = setup(input, offset); const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition); const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...scopes, ...intrinsics].map((s) => expect.objectContaining({ label: s })) [...scopes, ...intrinsicsV1].map((s) => expect.objectContaining({ label: s }))
); );
}); });

View File

@ -7,12 +7,7 @@ import type { Monaco, monacoTypes } from '@grafana/ui';
import TempoLanguageProvider from '../language_provider'; import TempoLanguageProvider from '../language_provider';
import { getSituation, Situation } from './situation'; import { getSituation, Situation } from './situation';
import { intrinsics, scopes } from './traceql'; import { scopes } from './traceql';
interface Props {
languageProvider: TempoLanguageProvider;
setAlertText: (text?: string) => void;
}
type MinimalCompletionItem = { type MinimalCompletionItem = {
label: string; label: string;
@ -21,6 +16,17 @@ type MinimalCompletionItem = {
documentation?: string | IMarkdownString; documentation?: string | IMarkdownString;
}; };
export type CompletionItemType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE' | 'FUNCTION';
type CompletionItem = MinimalCompletionItem & {
type: CompletionItemType;
insertTextRules?: monacoTypes.languages.CompletionItemInsertTextRule; // we used it to position the cursor
};
interface Props {
languageProvider: TempoLanguageProvider;
setAlertText: (text?: string) => void;
}
/** /**
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco * Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
* autocomplete system. * autocomplete system.
@ -322,7 +328,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
* @param situation * @param situation
* @private * @private
*/ */
private async getCompletions(situation: Situation, setAlertText: (text?: string) => void): Promise<Completion[]> { private async getCompletions(situation: Situation, setAlertText: (text?: string) => void): Promise<CompletionItem[]> {
switch (situation.type) { switch (situation.type) {
// This should only happen for cases that we do not support yet // This should only happen for cases that we do not support yet
case 'UNKNOWN': { case 'UNKNOWN': {
@ -339,42 +345,26 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
return this.getTagsCompletions(); return this.getTagsCompletions();
} }
case 'SPANSET_IN_THE_MIDDLE': case 'SPANSET_IN_THE_MIDDLE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE': case 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({ return this.getOperatorsCompletions([...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps]);
...key,
type: 'OPERATOR',
}));
case 'SPANSET_IN_NAME': case 'SPANSET_IN_NAME':
return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions()); return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions());
case 'SPANSET_IN_NAME_SCOPE': case 'SPANSET_IN_NAME_SCOPE':
return this.getTagsCompletions(undefined, situation.scope); return this.getTagsCompletions(undefined, situation.scope);
case 'SPANSET_EXPRESSION_OPERATORS': case 'SPANSET_EXPRESSION_OPERATORS':
return [ return this.getOperatorsCompletions([
...CompletionProvider.comparisonOps, ...CompletionProvider.comparisonOps,
...CompletionProvider.logicalOps, ...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps, ...CompletionProvider.arithmeticOps,
].map((key) => ({ ]);
...key,
type: 'OPERATOR',
}));
case 'SPANFIELD_COMBINING_OPERATORS': case 'SPANFIELD_COMBINING_OPERATORS':
return [ return this.getOperatorsCompletions([
...CompletionProvider.logicalOps, ...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps, ...CompletionProvider.arithmeticOps,
...CompletionProvider.comparisonOps, ...CompletionProvider.comparisonOps,
].map((key) => ({ ]);
...key,
type: 'OPERATOR',
}));
case 'SPANSET_COMBINING_OPERATORS': case 'SPANSET_COMBINING_OPERATORS':
return CompletionProvider.spansetOps.map((key) => ({ return this.getOperatorsCompletions(CompletionProvider.spansetOps);
...key,
type: 'OPERATOR',
}));
case 'SPANSET_PIPELINE_AFTER_OPERATOR': case 'SPANSET_PIPELINE_AFTER_OPERATOR':
const functions = CompletionProvider.functions.map((key) => ({ const functions = CompletionProvider.functions.map((key) => ({
...key, ...key,
@ -386,10 +376,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
.concat(this.getTagsCompletions('.')); .concat(this.getTagsCompletions('.'));
return [...functions, ...tags]; return [...functions, ...tags];
case 'SPANSET_COMPARISON_OPERATORS': case 'SPANSET_COMPARISON_OPERATORS':
return CompletionProvider.comparisonOps.map((key) => ({ return this.getOperatorsCompletions(CompletionProvider.comparisonOps);
...key,
type: 'OPERATOR',
}));
case 'SPANSET_IN_VALUE': case 'SPANSET_IN_VALUE':
let tagValues; let tagValues;
try { try {
@ -403,8 +390,6 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
} }
} }
const items: Completion[] = [];
const getInsertionText = (val: SelectableValue<string>): string => { const getInsertionText = (val: SelectableValue<string>): string => {
if (situation.betweenQuotes) { if (situation.betweenQuotes) {
return val.label!; return val.label!;
@ -412,6 +397,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
return val.type === 'string' ? `"${val.label}"` : val.label!; return val.type === 'string' ? `"${val.label}"` : val.label!;
}; };
const items: CompletionItem[] = [];
tagValues?.forEach((val) => { tagValues?.forEach((val) => {
if (val?.label) { if (val?.label) {
items.push({ items.push({
@ -439,7 +425,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
} }
} }
private getTagsCompletions(prepend?: string, scope?: string): Completion[] { private getTagsCompletions(prepend?: string, scope?: string): CompletionItem[] {
const tags = this.languageProvider.getTraceqlAutocompleteTags(scope); const tags = this.languageProvider.getTraceqlAutocompleteTags(scope);
return tags return tags
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' })) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' }))
@ -450,8 +436,8 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
})); }));
} }
private getIntrinsicsCompletions(prepend?: string, append?: string): Completion[] { private getIntrinsicsCompletions(prepend?: string, append?: string): CompletionItem[] {
return intrinsics.map((key) => ({ return this.languageProvider.getIntrinsics().map((key) => ({
label: key, label: key,
insertText: (prepend || '') + key + (append || ''), insertText: (prepend || '') + key + (append || ''),
type: 'KEYWORD', type: 'KEYWORD',
@ -459,7 +445,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
})); }));
} }
private getScopesCompletions(prepend?: string, append?: string): Completion[] { private getScopesCompletions(prepend?: string, append?: string): CompletionItem[] {
return scopes.map((key) => ({ return scopes.map((key) => ({
label: key, label: key,
insertText: (prepend || '') + key + (append || ''), insertText: (prepend || '') + key + (append || ''),
@ -467,6 +453,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet, insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet,
})); }));
} }
private getOperatorsCompletions(ops: MinimalCompletionItem[]): CompletionItem[] {
return ops.map((key) => ({
...key,
type: 'OPERATOR',
}));
}
} }
/** /**
@ -474,7 +467,10 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
* @param type * @param type
* @param monaco * @param monaco
*/ */
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { function getMonacoCompletionItemKind(
type: CompletionItemType,
monaco: Monaco
): monacoTypes.languages.CompletionItemKind {
switch (type) { switch (type) {
case 'TAG_NAME': case 'TAG_NAME':
return monaco.languages.CompletionItemKind.Enum; return monaco.languages.CompletionItemKind.Enum;
@ -489,25 +485,10 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona
case 'FUNCTION': case 'FUNCTION':
return monaco.languages.CompletionItemKind.Function; return monaco.languages.CompletionItemKind.Function;
default: default:
throw new Error(`Unexpected CompletionType: ${type}`); throw new Error(`Unexpected CompletionItemType: ${type}`);
} }
} }
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE' | 'FUNCTION';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
insertTextRules?: monacoTypes.languages.CompletionItemInsertTextRule; // we used it to position the cursor
documentation?: string | IMarkdownString;
detail?: string;
};
export type Tag = {
name: string;
value: string;
};
function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) { function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) {
const word = model.getWordAtPosition(position); const word = model.getWordAtPosition(position);
const range = const range =
@ -539,7 +520,7 @@ function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel,
*/ */
function fixSuggestion( function fixSuggestion(
suggestion: monacoTypes.languages.CompletionItem, suggestion: monacoTypes.languages.CompletionItem,
itemType: CompletionType, itemType: CompletionItemType,
model: monacoTypes.editor.ITextModel, model: monacoTypes.editor.ITextModel,
offset: number offset: number
) { ) {

View File

@ -116,7 +116,7 @@ export const getErrorNodes = (query: string): SyntaxNode[] => {
}; };
/** /**
* Use red markers (squiggles) to highlight syntax errors in queries. * Use markers (squiggles) to highlight syntax errors or warnings in queries.
* *
*/ */
export const setMarkers = ( export const setMarkers = (

View File

@ -161,18 +161,16 @@ export function getSituation(text: string, offset: number): Situation | null {
shiftedOffset -= 1; shiftedOffset -= 1;
} }
// if the tree contains error, it is very probable that // If the tree contains error, it's probable that our node is one of those error nodes.
// our node is one of those error nodes. // If there are errors, the node lezer finds us might not be the best node.
// also, if there are errors, the node lezer finds us, // So, first we check if there is an error node at the cursor position.
// might not be the best node. let errorNode = getErrorNode(tree, shiftedOffset);
// so first we check if there is an error node at the cursor position if (!errorNode) {
let maybeErrorNode = getErrorNode(tree, shiftedOffset); // Try again with the previous character.
if (!maybeErrorNode) { errorNode = getErrorNode(tree, shiftedOffset - 1);
// try again with the previous character
maybeErrorNode = getErrorNode(tree, shiftedOffset - 1);
} }
const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(shiftedOffset); const cur = errorNode != null ? errorNode.cursor() : tree.cursorAt(shiftedOffset);
const currentNode = cur.node; const currentNode = cur.node;
const ids = [cur.type.id]; const ids = [cur.type.id];

View File

@ -28,7 +28,7 @@ export const keywordOperators = ['=', '!='];
export const stringOperators = ['=', '!=', '=~', '!~']; export const stringOperators = ['=', '!=', '=~', '!~'];
export const numberOperators = ['=', '!=', '>', '<', '>=', '<=']; export const numberOperators = ['=', '!=', '>', '<', '>=', '<='];
export const intrinsics = [ export const intrinsicsV1 = [
'duration', 'duration',
'kind', 'kind',
'name', 'name',
@ -38,6 +38,24 @@ export const intrinsics = [
'statusMessage', 'statusMessage',
'traceDuration', 'traceDuration',
]; ];
export const intrinsics = intrinsicsV1.concat([
'event:name',
'event:timeSinceStart',
'instrumentation:name',
'instrumentation:version',
'link:spanID',
'link:traceID',
'span:duration',
'span:id',
'span:kind',
'span:name',
'span:status',
'span:statusMessage',
'trace:duration',
'trace:id',
'trace:rootName',
'trace:rootService',
]);
export const scopes: string[] = ['resource', 'span']; export const scopes: string[] = ['resource', 'span'];
const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum']; const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum'];