Jaeger: Add support for variables (#50500)

* Add service variable

* Interpolate vars in query

* Add var in service select if it exists

* Add var in operation select if it exists

* Interpolate tags and serivce in operation query

* Interpolate vars for explore

* Add/update tests

* Tests

* Update format for tags that do not have vars
This commit is contained in:
Joey Tawadrous 2022-06-16 08:17:38 +01:00 committed by GitHub
parent d88108a3b7
commit bd6c027a01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 146 additions and 19 deletions

View File

@ -14,6 +14,16 @@ import { JaegerQuery } from '../types';
import SearchForm from './SearchForm';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as any),
getTemplateSrv: () => ({
replace: jest.fn(),
containsTemplate: (val: string): boolean => {
return val.includes('$');
},
}),
}));
describe('SearchForm', () => {
it('should call the `onChange` function on click of the Input', async () => {
const promise = Promise.resolve();
@ -108,18 +118,47 @@ describe('SearchForm', () => {
render(<SearchForm datasource={ds} query={query} onChange={() => {}} />);
const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' });
expect(asyncServiceSelect).toBeInTheDocument();
await user.click(asyncServiceSelect);
jest.advanceTimersByTime(3000);
expect(asyncServiceSelect).toBeInTheDocument();
await user.type(asyncServiceSelect, 'j');
var option = await screen.findByText('jaeger-query');
expect(option).toBeDefined();
await user.type(asyncServiceSelect, 'c');
option = await screen.findByText('No options found');
option = await screen.findByText('Hit enter to add');
expect(option).toBeDefined();
});
it('should add variable to select menu options', async () => {
query = {
...defaultQuery,
refId: '121314',
service: '$service',
operation: '$operation',
};
render(<SearchForm datasource={ds} query={query} onChange={() => {}} />);
const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' });
expect(asyncServiceSelect).toBeInTheDocument();
await user.click(asyncServiceSelect);
jest.advanceTimersByTime(3000);
await user.type(asyncServiceSelect, '$');
var serviceOption = await screen.findByText('$service');
expect(serviceOption).toBeDefined();
const asyncOperationSelect = screen.getByRole('combobox', { name: 'select-operation-name' });
expect(asyncOperationSelect).toBeInTheDocument();
await user.click(asyncOperationSelect);
jest.advanceTimersByTime(3000);
await user.type(asyncOperationSelect, '$');
var operationOption = await screen.findByText('$operation');
expect(operationOption).toBeDefined();
});
});
function setupFetchMock(response: any, mock?: any) {

View File

@ -1,7 +1,8 @@
import { css } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, toOption } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { fuzzyMatch, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
@ -68,23 +69,29 @@ export function SearchForm({ datasource, query, onChange }: Props) {
useEffect(() => {
const getServices = async () => {
const services = await loadOptions('/api/services', 'services');
if (query.service && getTemplateSrv().containsTemplate(query.service)) {
services.push(toOption(query.service));
}
setServiceOptions(services);
};
getServices();
}, [datasource, loadOptions]);
}, [datasource, loadOptions, query.service]);
useEffect(() => {
const getOperations = async () => {
const operations = await loadOptions(
`/api/services/${encodeURIComponent(query.service!)}/operations`,
`/api/services/${encodeURIComponent(getTemplateSrv().replace(query.service!))}/operations`,
'operations'
);
if (query.operation && getTemplateSrv().containsTemplate(query.operation)) {
operations.push(toOption(query.operation));
}
setOperationOptions([allOperationsOption, ...operations]);
};
if (query.service) {
getOperations();
}
}, [datasource, query.service, loadOptions]);
}, [datasource, query.service, loadOptions, query.operation]);
return (
<div className={css({ maxWidth: '500px' })}>
@ -106,6 +113,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {
menuPlacement="bottom"
isClearable
aria-label={'select-service-name'}
allowCustomValue={true}
/>
</InlineField>
</InlineFieldRow>
@ -115,7 +123,10 @@ export function SearchForm({ datasource, query, onChange }: Props) {
inputId="operation"
options={operationOptions}
onOpenMenu={() =>
loadOptions(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations')
loadOptions(
`/api/services/${encodeURIComponent(getTemplateSrv().replace(query.service!))}/operations`,
'operations'
)
}
isLoading={isLoading.operations}
value={operationOptions?.find((v) => v.value === query.operation) || null}
@ -128,6 +139,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {
menuPlacement="bottom"
isClearable
aria-label={'select-operation-name'}
allowCustomValue={true}
/>
</InlineField>
</InlineFieldRow>

View File

@ -29,6 +29,9 @@ jest.mock('@grafana/runtime', () => ({
replace: (val: string, subs: ScopedVars): string => {
return subs[val]?.value ?? val;
},
containsTemplate: (val: string): boolean => {
return val.includes('$');
},
}),
}));
@ -124,7 +127,7 @@ describe('JaegerDatasource', () => {
})
);
expect(mock).toBeCalledWith({
url: `${defaultSettings.url}/api/traces?operation=%2Fapi%2Fservices&service=jaeger-query&start=1531468681000&end=1531489712000&lookback=custom`,
url: `${defaultSettings.url}/api/traces?service=jaeger-query&operation=%2Fapi%2Fservices&start=1531468681000&end=1531489712000&lookback=custom`,
});
expect(response.data[0].meta.preferredVisualisationType).toBe('table');
// Make sure that traceID field has data link configured
@ -205,6 +208,36 @@ describe('JaegerDatasource', () => {
url: `${defaultSettings.url}/api/traces?service=jaeger-query&tags=%7B%22error%22%3A%22true%22%7D&start=1531468681000&end=1531489712000&lookback=custom`,
});
});
it('should interpolate variables correctly', async () => {
const mock = setupFetchMock({ data: [testResponse] });
const ds = new JaegerDatasource(defaultSettings, timeSrvStub);
const text = 'interpolationText';
await lastValueFrom(
ds.query({
...defaultQuery,
scopedVars: {
$interpolationVar: {
text: text,
value: text,
},
},
targets: [
{
queryType: 'search',
refId: 'a',
service: '$interpolationVar',
operation: '$interpolationVar',
minDuration: '$interpolationVar',
maxDuration: '$interpolationVar',
},
],
})
);
expect(mock).toBeCalledWith({
url: `${defaultSettings.url}/api/traces?service=interpolationText&operation=interpolationText&minDuration=interpolationText&maxDuration=interpolationText&start=1531468681000&end=1531489712000&lookback=custom`,
});
});
});
describe('when performing testDataSource', () => {

View File

@ -12,8 +12,9 @@ import {
DateTime,
FieldType,
MutableDataFrame,
ScopedVars,
} from '@grafana/data';
import { BackendSrvRequest, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
import { BackendSrvRequest, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings';
import { serializeParams } from 'app/core/utils/fetch';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@ -33,7 +34,8 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery, JaegerJsonData>
nodeGraph?: NodeGraphOptions;
constructor(
private instanceSettings: DataSourceInstanceSettings<JaegerJsonData>,
private readonly timeSrv: TimeSrv = getTimeSrv()
private readonly timeSrv: TimeSrv = getTimeSrv(),
private readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings);
this.nodeGraph = instanceSettings.jsonData.nodeGraph;
@ -54,7 +56,7 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery, JaegerJsonData>
if (target.queryType !== 'search' && target.query) {
return this._request(
`/api/traces/${encodeURIComponent(getTemplateSrv().replace(target.query, options.scopedVars))}`
`/api/traces/${encodeURIComponent(this.templateSrv.replace(target.query, options.scopedVars))}`
).pipe(
map((response) => {
const traceData = response?.data?.data?.[0];
@ -89,20 +91,28 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery, JaegerJsonData>
}
}
let jaegerQuery = pick(target, ['operation', 'service', 'tags', 'minDuration', 'maxDuration', 'limit']);
let jaegerInterpolated = pick(this.applyVariables(target, options.scopedVars), [
'service',
'operation',
'tags',
'minDuration',
'maxDuration',
'limit',
]);
// remove empty properties
jaegerQuery = pickBy(jaegerQuery, identity);
if (jaegerQuery.tags) {
jaegerQuery = {
...jaegerQuery,
tags: convertTagsLogfmt(getTemplateSrv().replace(jaegerQuery.tags, options.scopedVars)),
};
}
let jaegerQuery = pickBy(jaegerInterpolated, identity);
if (jaegerQuery.operation === ALL_OPERATIONS_KEY) {
jaegerQuery = omit(jaegerQuery, 'operation');
}
if (jaegerQuery.tags) {
jaegerQuery = {
...jaegerQuery,
tags: convertTagsLogfmt(jaegerQuery.tags.toString()),
};
}
// TODO: this api is internal, used in jaeger ui. Officially they have gRPC api that should be used.
return this._request(`/api/traces`, {
...jaegerQuery,
@ -117,6 +127,39 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery, JaegerJsonData>
);
}
interpolateVariablesInQueries(queries: JaegerQuery[], scopedVars: ScopedVars): JaegerQuery[] {
if (!queries || queries.length === 0) {
return [];
}
return queries.map((query) => {
return {
...query,
datasource: this.getRef(),
...this.applyVariables(query, scopedVars),
};
});
}
applyVariables(query: JaegerQuery, scopedVars: ScopedVars) {
let expandedQuery = { ...query };
if (query.tags && this.templateSrv.containsTemplate(query.tags)) {
expandedQuery = {
...query,
tags: this.templateSrv.replace(query.tags, scopedVars),
};
}
return {
...expandedQuery,
service: this.templateSrv.replace(query.service ?? '', scopedVars),
operation: this.templateSrv.replace(query.operation ?? '', scopedVars),
minDuration: this.templateSrv.replace(query.minDuration ?? '', scopedVars),
maxDuration: this.templateSrv.replace(query.maxDuration ?? '', scopedVars),
};
}
async testDatasource(): Promise<any> {
return lastValueFrom(
this._request('/api/services').pipe(