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

View File

@ -53,7 +53,10 @@ const SearchField = ({
}: Props) => {
const styles = useStyles2(getStyles);
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
// 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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import { GroupByField } from './GroupByField';
import InlineSearchField from './InlineSearchField';
import SearchField from './SearchField';
import TagsInput from './TagsInput';
import { filterScopedTag, filterTitle, generateQueryFromFilters, interpolateFilters, replaceAt } from './utils';
import { filterScopedTag, filterTitle, interpolateFilters, replaceAt } from './utils';
interface Props {
datasource: TempoDatasource;
@ -64,8 +64,8 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
const templateVariables = getTemplateSrv().getVariables();
useEffect(() => {
setTraceQlQuery(generateQueryFromFilters(interpolateFilters(query.filters || [])));
}, [query, templateVariables]);
setTraceQlQuery(datasource.languageProvider.generateQueryFromFilters(interpolateFilters(query.filters || [])));
}, [datasource.languageProvider, query, templateVariables]);
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) || [];
staticTags.push('duration');
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
// 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 && (
<InlineSearchField
key={f.id}
label={filterTitle(f)}
label={filterTitle(f, datasource.languageProvider)}
tooltip={`Filter your search by ${filterScopedTag(
f
f,
datasource.languageProvider
)}. To modify the default filters shown for search visit the Tempo datasource configuration page.`}
>
<SearchField
@ -242,7 +247,7 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
});
onClearResults();
const traceQlQuery = generateQueryFromFilters(query.filters || []);
const traceQlQuery = datasource.languageProvider.generateQueryFromFilters(query.filters || []);
onChange({
...query,
query: traceQlQuery,

View File

@ -1,242 +1,84 @@
import { uniq } from 'lodash';
import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { intrinsicsV1 } from '../traceql/traceql';
import {
generateQueryFromFilters,
getUnscopedTags,
getFilteredTags,
getAllTags,
getTagsByScope,
generateQueryFromAdHocFilters,
} from './utils';
import { getUnscopedTags, getFilteredTags, getAllTags, getTagsByScope, generateQueryFromAdHocFilters } from './utils';
describe('generateQueryFromFilters generates the correct query for', () => {
it('an empty array', () => {
expect(generateQueryFromFilters([])).toBe('{}');
});
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',
const datasource: TempoDatasource = {
search: {
filters: [],
},
])
).toBe('{.footag>=1234}');
});
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}');
});
});
} as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource);
describe('generateQueryFromAdHocFilters generates the correct query for', () => {
it('an empty array', () => {
expect(generateQueryFromAdHocFilters([])).toBe('{}');
expect(generateQueryFromAdHocFilters([], lp)).toBe('{}');
});
it('a filter with values', () => {
expect(generateQueryFromAdHocFilters([{ key: 'footag', operator: '=', value: 'foovalue' }])).toBe(
expect(generateQueryFromAdHocFilters([{ key: 'footag', operator: '=', value: 'foovalue' }], lp)).toBe(
'{footag="foovalue"}'
);
});
it('two filters with values', () => {
expect(
generateQueryFromAdHocFilters([
generateQueryFromAdHocFilters(
[
{ key: 'footag', operator: '=', value: 'foovalue' },
{ key: 'bartag', operator: '=', value: '0' },
])
],
lp
)
).toBe('{footag="foovalue" && bartag=0}');
});
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', () => {
const datasource: TempoDatasource = {
search: {
filters: [],
},
} as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource);
it('for filtered tags when no tags supplied', () => {
const tags = getFilteredTags(emptyTags, []);
expect(tags).toEqual([
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
]);
const tags = getFilteredTags(emptyTags, lp, []);
expect(tags).toEqual(intrinsicsV1);
});
it('for filtered tags when API v1 tags supplied', () => {
const tags = getFilteredTags(v1Tags, []);
expect(tags).toEqual([
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'bar',
'foo',
]);
const tags = getFilteredTags(v1Tags, lp, []);
expect(tags).toEqual(intrinsicsV1.concat(['bar', 'foo']));
});
it('for filtered tags when API v1 tags supplied with tags to filter out', () => {
const tags = getFilteredTags(v1Tags, ['duration']);
expect(tags).toEqual([
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'bar',
'foo',
]);
const tags = getFilteredTags(v1Tags, lp, ['duration']);
expect(tags).toEqual(intrinsicsV1.filter((x) => x !== 'duration').concat(['bar', 'foo']));
});
it('for filtered tags when API v2 tags supplied', () => {
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), []);
expect(tags).toEqual([
'duration',
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'cluster',
'container',
'db',
]);
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), lp, []);
expect(tags).toEqual(intrinsicsV1.concat(['cluster', 'container', 'db']));
});
it('for filtered tags when API v2 tags supplied with tags to filter out', () => {
const tags = getFilteredTags(getUnscopedTags(v2Tags), ['duration', 'cluster']);
expect(tags).toEqual([
'kind',
'name',
'rootName',
'rootServiceName',
'status',
'statusMessage',
'traceDuration',
'container',
'db',
]);
const tags = getFilteredTags(getUnscopedTags(v2Tags), lp, ['duration', 'cluster']);
expect(tags).toEqual(intrinsicsV1.filter((x) => x !== 'duration').concat(['container', 'db']));
});
it('for filtered tags when API v2 tags set', () => {
lp.setV2Tags(v2Tags);
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), lp, []);
expect(tags).toEqual(testIntrinsics.concat(['cluster', 'container', 'db']));
});
it('for unscoped tags', () => {
@ -261,6 +103,7 @@ describe('gets correct tags', () => {
});
export const emptyTags = [];
export const testIntrinsics = ['duration', 'kind', 'name', 'status'];
export const v1Tags = ['bar', 'foo'];
export const v2Tags = [
{
@ -273,6 +116,6 @@ export const v2Tags = [
},
{
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 { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { intrinsics } from '../traceql/traceql';
import TempoLanguageProvider from '../language_provider';
import { Scope } from '../types';
export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: ScopedVars) => {
@ -28,18 +28,7 @@ export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: Scoped
return interpolatedFilters;
};
export const generateQueryFromFilters = (filters: 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) => {
export const valueHelper = (f: TraceqlFilter) => {
if (Array.isArray(f.value) && f.value.length > 1) {
return `"${f.value.join('|')}"`;
}
@ -49,9 +38,9 @@ const valueHelper = (f: TraceqlFilter) => {
return f.value;
};
const scopeHelper = (f: TraceqlFilter) => {
export const scopeHelper = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
// Intrinsic fields don't have a scope
if (intrinsics.find((t) => t === f.tag)) {
if (lp.getIntrinsics().find((t) => t === f.tag)) {
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') {
const durationType = filters.find((f) => f.id === 'duration-type');
if (durationType) {
@ -70,15 +59,15 @@ const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => {
return f.tag;
};
export const generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[]) => {
export const generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[], lp: TempoLanguageProvider) => {
return `{${filters
.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(' && ')}}`;
};
const adHocValueHelper = (f: AdHocVariableFilter) => {
if (intrinsics.find((t) => t === f.key)) {
const adHocValueHelper = (f: AdHocVariableFilter, lp: TempoLanguageProvider) => {
if (lp.getIntrinsics().find((t) => t === f.key)) {
return 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)\./, '');
};
export const filterScopedTag = (f: TraceqlFilter) => {
return scopeHelper(f) + f.tag;
export const filterScopedTag = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
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
if (f.tag === 'name') {
return 'Span Name';
@ -104,16 +93,34 @@ export const filterTitle = (f: TraceqlFilter) => {
if (f.tag === 'service.name' && f.scope === TraceqlSearchScope.Resource) {
return 'Service Name';
}
return startCase(filterScopedTag(f));
return startCase(filterScopedTag(f, lp));
};
export const getFilteredTags = (tags: string[], staticTags: Array<string | undefined>) => {
return [...intrinsics, ...tags].filter((t) => !staticTags.includes(t));
export const getFilteredTags = (
tags: string[],
languageProvider: TempoLanguageProvider,
staticTags: Array<string | undefined>
) => {
return [...languageProvider.getIntrinsics(), ...tags].filter((t) => !staticTags.includes(t));
};
export const getUnscopedTags = (scopes: Scope[]) => {
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';
import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema';
import {
generateQueryFromAdHocFilters,
generateQueryFromFilters,
getTagWithoutScope,
interpolateFilters,
} from './SearchTraceQLEditor/utils';
import { generateQueryFromAdHocFilters, getTagWithoutScope, interpolateFilters } from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
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
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);
}
@ -382,9 +377,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
const target = targets.traceqlSearch.find((t) => this.hasGroupBy(t));
if (target) {
const appliedQuery = this.applyVariables(target, options.scopedVars);
subQueries.push(
this.handleMetricsSummaryQuery(appliedQuery, generateQueryFromFilters(appliedQuery.filters), options)
);
const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
subQueries.push(this.handleMetricsSummaryQuery(appliedQuery, queryFromFilters, options));
}
}
@ -393,22 +387,22 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
: targets.traceqlSearch;
if (traceqlSearchTargets.length > 0) {
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', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
query: queryValueFromFilters ?? '',
query: queryFromFilters ?? '',
streaming: this.isStreamingSearchEnabled(),
});
if (this.isStreamingSearchEnabled()) {
subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryValueFromFilters));
subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryFromFilters));
} else {
subQueries.push(
this._request('/api/search', {
q: queryValueFromFilters,
q: queryFromFilters,
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
spss: options.targets[0].spss ?? DEFAULT_SPSS,
start: options.range.from.unix(),
@ -831,7 +825,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}
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 datasource: TempoDatasource = {
search: {

View File

@ -2,9 +2,18 @@ import { LanguageProvider, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { VariableFormatID } from '@grafana/schema';
import { getAllTags, getTagsByScope, getUnscopedTags } from './SearchTraceQLEditor/utils';
import { TraceqlSearchScope } from './dataquery.gen';
import {
getAllTags,
getIntrinsicTags,
getTagsByScope,
getUnscopedTags,
scopeHelper,
tagHelper,
valueHelper,
} from './SearchTraceQLEditor/utils';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import { TempoDatasource } from './datasource';
import { intrinsicsV1 } from './traceql/traceql';
import { Scope } from './types';
export default class TempoLanguageProvider extends LanguageProvider {
@ -56,6 +65,13 @@ export default class TempoLanguageProvider extends LanguageProvider {
this.tagsV2 = tags;
};
getIntrinsics = () => {
if (this.tagsV2) {
return getIntrinsicTags(this.tagsV2);
}
return intrinsicsV1;
};
getTags = (scope?: TraceqlSearchScope) => {
if (this.tagsV2 && scope) {
if (scope === TraceqlSearchScope.Unscoped) {
@ -164,4 +180,15 @@ export default class TempoLanguageProvider extends LanguageProvider {
// Reference: https://stackoverflow.com/a/37456192
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 { Button, InlineLabel, useStyles2 } from '@grafana/ui';
import { generateQueryFromFilters } from '../SearchTraceQLEditor/utils';
import { TempoDatasource } from '../datasource';
import { defaultQuery, MyDataSourceOptions, TempoQuery } from '../types';
@ -23,7 +22,7 @@ export function QueryEditor(props: Props) {
const styles = useStyles2(getStyles);
const query = defaults(props.query, defaultQuery);
const [showCopyFromSearchButton, setShowCopyFromSearchButton] = useState(() => {
const genQuery = generateQueryFromFilters(query.filters || []);
const genQuery = props.datasource.languageProvider.generateQueryFromFilters(query.filters || []);
return genQuery === query.query || genQuery === '{}';
});
@ -51,7 +50,7 @@ export function QueryEditor(props: Props) {
props.onClearResults();
props.onChange({
...query,
query: generateQueryFromFilters(query.filters || []),
query: props.datasource.languageProvider.generateQueryFromFilters(query.filters || []),
});
setShowCopyFromSearchButton(true);
}}

View File

@ -9,7 +9,7 @@ import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { TempoDatasource } from '../datasource';
import { TempoQuery } from '../types';
import { CompletionProvider, CompletionType } from './autocomplete';
import { CompletionProvider, CompletionItemType } from './autocomplete';
import { getErrorNodes, setMarkers } from './highlighting';
import { languageDefinition } from './traceql';
@ -109,7 +109,7 @@ export function TraceQLEditor(props: Props) {
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(() => {
setMarkers(monaco, model, errorNodes);
}, 500);
@ -126,7 +126,7 @@ function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, mona
{
range: new monaco.Range(1, 1, 1, 1),
options: {
className: styles.placeholder, // The placeholder text is in styles.placeholder
className: styles.placeholder,
isWholeLine: true,
},
},
@ -163,7 +163,7 @@ function setupActions(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco:
}
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 };
// Filter out the label for TAG_VALUE completions to avoid potentially exposing sensitive data
if (type !== 'TAG_VALUE') {

View File

@ -1,13 +1,13 @@
import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data';
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 TempoLanguageProvider from '../language_provider';
import { Scope, TempoJsonData } from '../types';
import { CompletionProvider } from './autocomplete';
import { intrinsics, scopes } from './traceql';
import { intrinsicsV1, scopes } from './traceql';
const emptyPosition = {} as monacoTypes.Position;
@ -21,7 +21,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...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: 'foo', insertText: '.foo' }),
expect.objectContaining({ label: 'status', insertText: '.status' }),
@ -33,7 +33,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...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: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }),
@ -138,7 +138,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...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: 'foo', insertText: '{ .foo' }),
expect.objectContaining({ label: 'status', insertText: '{ .status' }),
@ -150,7 +150,7 @@ describe('CompletionProvider', () => {
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...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: 'container', insertText: '{ .container' }),
expect.objectContaining({ label: 'db', insertText: '{ .db' }),
@ -229,7 +229,7 @@ describe('CompletionProvider', () => {
expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation })
),
...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: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }),
@ -361,7 +361,7 @@ describe('CompletionProvider', () => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
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 { getSituation, Situation } from './situation';
import { intrinsics, scopes } from './traceql';
interface Props {
languageProvider: TempoLanguageProvider;
setAlertText: (text?: string) => void;
}
import { scopes } from './traceql';
type MinimalCompletionItem = {
label: string;
@ -21,6 +16,17 @@ type MinimalCompletionItem = {
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
* autocomplete system.
@ -322,7 +328,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
* @param situation
* @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) {
// This should only happen for cases that we do not support yet
case 'UNKNOWN': {
@ -339,42 +345,26 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
return this.getTagsCompletions();
}
case 'SPANSET_IN_THE_MIDDLE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({
...key,
type: 'OPERATOR',
}));
case 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE':
return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({
...key,
type: 'OPERATOR',
}));
return this.getOperatorsCompletions([...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps]);
case 'SPANSET_IN_NAME':
return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions());
case 'SPANSET_IN_NAME_SCOPE':
return this.getTagsCompletions(undefined, situation.scope);
case 'SPANSET_EXPRESSION_OPERATORS':
return [
return this.getOperatorsCompletions([
...CompletionProvider.comparisonOps,
...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps,
].map((key) => ({
...key,
type: 'OPERATOR',
}));
]);
case 'SPANFIELD_COMBINING_OPERATORS':
return [
return this.getOperatorsCompletions([
...CompletionProvider.logicalOps,
...CompletionProvider.arithmeticOps,
...CompletionProvider.comparisonOps,
].map((key) => ({
...key,
type: 'OPERATOR',
}));
]);
case 'SPANSET_COMBINING_OPERATORS':
return CompletionProvider.spansetOps.map((key) => ({
...key,
type: 'OPERATOR',
}));
return this.getOperatorsCompletions(CompletionProvider.spansetOps);
case 'SPANSET_PIPELINE_AFTER_OPERATOR':
const functions = CompletionProvider.functions.map((key) => ({
...key,
@ -386,10 +376,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
.concat(this.getTagsCompletions('.'));
return [...functions, ...tags];
case 'SPANSET_COMPARISON_OPERATORS':
return CompletionProvider.comparisonOps.map((key) => ({
...key,
type: 'OPERATOR',
}));
return this.getOperatorsCompletions(CompletionProvider.comparisonOps);
case 'SPANSET_IN_VALUE':
let tagValues;
try {
@ -403,8 +390,6 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
}
}
const items: Completion[] = [];
const getInsertionText = (val: SelectableValue<string>): string => {
if (situation.betweenQuotes) {
return val.label!;
@ -412,6 +397,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
return val.type === 'string' ? `"${val.label}"` : val.label!;
};
const items: CompletionItem[] = [];
tagValues?.forEach((val) => {
if (val?.label) {
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);
return tags
.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[] {
return intrinsics.map((key) => ({
private getIntrinsicsCompletions(prepend?: string, append?: string): CompletionItem[] {
return this.languageProvider.getIntrinsics().map((key) => ({
label: key,
insertText: (prepend || '') + key + (append || ''),
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) => ({
label: key,
insertText: (prepend || '') + key + (append || ''),
@ -467,6 +453,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
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 monaco
*/
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
function getMonacoCompletionItemKind(
type: CompletionItemType,
monaco: Monaco
): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'TAG_NAME':
return monaco.languages.CompletionItemKind.Enum;
@ -489,25 +485,10 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona
case 'FUNCTION':
return monaco.languages.CompletionItemKind.Function;
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) {
const word = model.getWordAtPosition(position);
const range =
@ -539,7 +520,7 @@ function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel,
*/
function fixSuggestion(
suggestion: monacoTypes.languages.CompletionItem,
itemType: CompletionType,
itemType: CompletionItemType,
model: monacoTypes.editor.ITextModel,
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 = (

View File

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

View File

@ -28,7 +28,7 @@ export const keywordOperators = ['=', '!='];
export const stringOperators = ['=', '!=', '=~', '!~'];
export const numberOperators = ['=', '!=', '>', '<', '>=', '<='];
export const intrinsics = [
export const intrinsicsV1 = [
'duration',
'kind',
'name',
@ -38,6 +38,24 @@ export const intrinsics = [
'statusMessage',
'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'];
const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum'];