Traces: Filter by service/span name and operation in Tempo and Jaeger (#48209)

* Filter search options in Tempo

* Tests for filtering search options in Tempo

* Filter search options in Jaeger

* Tests for filtering search options in Jaeger

* Self review

* Fuzzy search
This commit is contained in:
Joey Tawadrous 2022-04-29 09:52:11 +01:00 committed by GitHub
parent 9e84e20ade
commit 412dbc21e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 111 additions and 52 deletions

View File

@ -20,12 +20,6 @@ describe('SearchForm', () => {
const handleOnChange = jest.fn(() => promise);
const query = {
...defaultQuery,
targets: [
{
query: 'a/b',
refId: '1',
},
],
refId: '121314',
};
const ds = {
@ -53,12 +47,6 @@ describe('SearchForm', () => {
const handleOnChange = jest.fn(() => promise);
const query2 = {
...defaultQuery,
targets: [
{
query: 'a/b',
refId: '1',
},
],
refId: '121314',
service: 'jaeger-query',
};
@ -73,35 +61,23 @@ describe('SearchForm', () => {
describe('SearchForm', () => {
let user: UserEvent;
let query: JaegerQuery;
let ds: JaegerDatasource;
beforeEach(() => {
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
});
it('should show loader if there is a delay fetching options', async () => {
const handleOnChange = jest.fn();
const query = {
query = {
...defaultQuery,
targets: [
{
query: 'a/b',
refId: '1',
},
],
refId: '121314',
service: 'jaeger-query',
};
const ds = new JaegerDatasource(defaultSettings);
setupFetchMock({ data: [testResponse] });
render(<SearchForm datasource={ds} query={query} onChange={handleOnChange} />);
ds = new JaegerDatasource(defaultSettings);
setupFetchMock({ data: [testResponse] });
jest.spyOn(ds, 'metadataRequest').mockImplementation(() => {
return new Promise((resolve) => {
@ -110,6 +86,16 @@ describe('SearchForm', () => {
}, 3000);
});
});
});
afterEach(() => {
jest.useRealTimers();
});
it('should show loader if there is a delay fetching options', async () => {
const handleOnChange = jest.fn();
render(<SearchForm datasource={ds} query={query} onChange={handleOnChange} />);
const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' });
await user.click(asyncServiceSelect);
expect(screen.getByText('Loading options...')).toBeInTheDocument();
@ -117,6 +103,25 @@ describe('SearchForm', () => {
jest.advanceTimersByTime(3000);
await waitFor(() => expect(screen.queryByText('Loading options...')).not.toBeInTheDocument());
});
it('should filter the span dropdown when user types a search value', async () => {
render(<SearchForm datasource={ds} query={query} onChange={() => {}} />);
const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' });
await user.click(asyncServiceSelect);
jest.advanceTimersByTime(3000);
expect(asyncServiceSelect).toBeInTheDocument();
await user.type(asyncServiceSelect, 'j');
jest.advanceTimersByTime(3000);
var option = await screen.findByText('jaeger-query');
expect(option).toBeDefined();
await user.type(asyncServiceSelect, 'c');
jest.advanceTimersByTime(3000);
option = await screen.findByText('No options found');
expect(option).toBeDefined();
});
});
function setupFetchMock(response: any, mock?: any) {

View File

@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { AsyncSelect, InlineField, InlineFieldRow, Input } from '@grafana/ui';
import { AsyncSelect, fuzzyMatch, InlineField, InlineFieldRow, Input } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
@ -37,7 +38,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {
});
const loadServices = useCallback(
async (url: string, loaderOfType: string): Promise<Array<SelectableValue<string>>> => {
async (url: string, loaderOfType: string, query = ''): Promise<Array<SelectableValue<string>>> => {
setIsLoading((prevValue) => ({ ...prevValue, [loaderOfType]: true }));
try {
@ -50,7 +51,11 @@ export function SearchForm({ datasource, query, onChange }: Props) {
label: service,
value: service,
}));
return serviceOptions;
const filteredOptions = serviceOptions.filter((item) =>
item.value ? fuzzyMatch(item.value, query).found : false
);
return filteredOptions;
} catch (error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
return [];
@ -61,6 +66,17 @@ export function SearchForm({ datasource, query, onChange }: Props) {
[datasource]
);
const getServiceOptions = (userQuery: string) => {
return loadServices('/api/services', 'services', userQuery);
};
const getOperationOptions = (userQuery: string) => {
return loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations', userQuery);
};
const serviceSearch = debounce(getServiceOptions, 500, { leading: true, trailing: true });
const operationSearch = debounce(getOperationOptions, 500, { leading: true, trailing: true });
useEffect(() => {
const getServices = async () => {
const services = await loadServices('/api/services', 'services');
@ -90,7 +106,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {
inputId="service"
menuShouldPortal
cacheOptions={false}
loadOptions={() => loadServices('/api/services', 'services')}
loadOptions={serviceSearch}
onOpenMenu={() => loadServices('/api/services', 'services')}
isLoading={isLoading.services}
value={serviceOptions?.find((v) => v?.value === query.service) || undefined}
@ -114,9 +130,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {
inputId="operation"
menuShouldPortal
cacheOptions={false}
loadOptions={() =>
loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations')
}
loadOptions={operationSearch}
onOpenMenu={() =>
loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations')
}

View File

@ -99,4 +99,26 @@ describe('NativeSearch', () => {
expect(handleOnChange).toHaveBeenCalledWith(fakeOptionChoice);
});
it('should filter the span dropdown when user types a search value', async () => {
render(
<NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={() => {}} onRunQuery={() => {}} />
);
const asyncServiceSelect = await screen.findByRole('combobox', { name: 'select-span-name' });
expect(asyncServiceSelect).toBeInTheDocument();
await user.click(asyncServiceSelect);
jest.advanceTimersByTime(1000);
await user.type(asyncServiceSelect, 'd');
jest.advanceTimersByTime(1000);
var option = await screen.findByText('driver');
expect(option).toBeDefined();
await user.type(asyncServiceSelect, 'a');
jest.advanceTimersByTime(1000);
option = await screen.findByText('No options found');
expect(option).toBeDefined();
});
});

View File

@ -17,6 +17,7 @@ import {
AsyncSelect,
Alert,
useStyles2,
fuzzyMatch,
} from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
@ -66,17 +67,18 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
spanName: false,
});
async function fetchOptionsCallback(nameType: string, lp: TempoLanguageProvider) {
async function fetchOptionsCallback(name: string, lp: TempoLanguageProvider, query = '') {
try {
const res = await lp.getOptions(nameType === 'serviceName' ? 'service.name' : 'name');
setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false }));
return res;
setIsLoading((prevValue) => ({ ...prevValue, [name]: false }));
const options = await lp.getOptions(name);
const filteredOptions = options.filter((item) => (item.value ? fuzzyMatch(item.value, query).found : false));
return filteredOptions;
} catch (error) {
if (error?.status === 404) {
setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false }));
setIsLoading((prevValue) => ({ ...prevValue, [name]: false }));
} else {
dispatch(notifyApp(createErrorNotification('Error', error)));
setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false }));
setIsLoading((prevValue) => ({ ...prevValue, [name]: false }));
}
setError(error);
return [];
@ -84,24 +86,40 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
}
const loadOptionsOfType = useCallback(
(nameType: string) => {
setIsLoading((prevValue) => ({ ...prevValue, [nameType]: true }));
return fetchOptionsCallback(nameType, languageProvider);
(name: string) => {
setIsLoading((prevValue) => ({ ...prevValue, [name]: true }));
return fetchOptionsCallback(name, languageProvider);
},
[languageProvider]
);
const fetchOptionsOfType = useCallback(
(nameType: string) => debounce(() => loadOptionsOfType(nameType), 500, { leading: true, trailing: true }),
(name: string) => debounce(() => loadOptionsOfType(name), 500, { leading: true, trailing: true }),
[loadOptionsOfType]
);
const getNameOptions = (query: string, name: string) => {
setIsLoading((prevValue) => ({ ...prevValue, [name]: true }));
return fetchOptionsCallback(name, languageProvider, query);
};
const getServiceNameOptions = (query: string) => {
return getNameOptions(query, 'service.name');
};
const getSpanNameOptions = (query: string) => {
return getNameOptions(query, 'name');
};
const serviceNameSearch = debounce(getServiceNameOptions, 500, { leading: true, trailing: true });
const spanNameSearch = debounce(getSpanNameOptions, 500, { leading: true, trailing: true });
useEffect(() => {
const fetchOptions = async () => {
try {
await languageProvider.start();
fetchOptionsCallback('serviceName', languageProvider);
fetchOptionsCallback('spanName', languageProvider);
fetchOptionsCallback('service.name', languageProvider);
fetchOptionsCallback('name', languageProvider);
setHasSyntaxLoaded(true);
} catch (error) {
// Display message if Tempo is connected but search 404's
@ -143,8 +161,8 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
inputId="service"
menuShouldPortal
cacheOptions={false}
loadOptions={fetchOptionsOfType('serviceName')}
onOpenMenu={fetchOptionsOfType('serviceName')}
loadOptions={serviceNameSearch}
onOpenMenu={fetchOptionsOfType('service.name')}
isLoading={isLoading.serviceName}
value={asyncServiceNameValue.value}
onChange={(v) => {
@ -170,8 +188,8 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
inputId="spanName"
menuShouldPortal
cacheOptions={false}
loadOptions={fetchOptionsOfType('spanName')}
onOpenMenu={fetchOptionsOfType('spanName')}
loadOptions={spanNameSearch}
onOpenMenu={fetchOptionsOfType('name')}
isLoading={isLoading.spanName}
value={asyncSpanNameValue.value}
onChange={(v) => {