Tempo: Add filtering for service graph query (#41162)

* Add filter based on AdHocFilter element

* Add tests

* Cancel layout in case we have have new data or we unmount node graph

* Fix typing

* Fix test
This commit is contained in:
Andrej Ocenas
2021-11-11 14:27:59 +01:00
committed by GitHub
parent f6ad3e420a
commit 5cc9ff8b28
16 changed files with 468 additions and 118 deletions

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
InlineFieldRow,
InlineField,
Input,
QueryField,
SlatePrism,
BracesPlugin,
TypeaheadInput,
TypeaheadOutput,
Select,
Alert,
useStyles2,
} from '@grafana/ui';
import { tokenizer } from '../syntax';
import Prism from 'prismjs';
import { Node } from 'slate';
import { css } from '@emotion/css';
import { GrafanaTheme2, isValidGoDuration, SelectableValue } from '@grafana/data';
import TempoLanguageProvider from '../language_provider';
import { TempoDatasource, TempoQuery } from '../datasource';
import { debounce } from 'lodash';
import { dispatch } from 'app/store/store';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
interface Props {
datasource: TempoDatasource;
query: TempoQuery;
onChange: (value: TempoQuery) => void;
onBlur?: () => void;
onRunQuery: () => void;
}
const PRISM_LANGUAGE = 'tempo';
const durationPlaceholder = 'e.g. 1.2s, 100ms';
const plugins = [
BracesPlugin(),
SlatePrism({
onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
getSyntax: () => PRISM_LANGUAGE,
}),
];
Prism.languages[PRISM_LANGUAGE] = tokenizer;
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => {
const styles = useStyles2(getStyles);
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false);
const [autocomplete, setAutocomplete] = useState<{
serviceNameOptions: Array<SelectableValue<string>>;
spanNameOptions: Array<SelectableValue<string>>;
}>({
serviceNameOptions: [],
spanNameOptions: [],
});
const [error, setError] = useState(null);
const [inputErrors, setInputErrors] = useState<{ [key: string]: boolean }>({});
const fetchServiceNameOptions = useMemo(
() =>
debounce(
async () => {
const res = await languageProvider.getOptions('service.name');
setAutocomplete((prev) => ({ ...prev, serviceNameOptions: res }));
},
500,
{ leading: true, trailing: true }
),
[languageProvider]
);
const fetchSpanNameOptions = useMemo(
() =>
debounce(
async () => {
const res = await languageProvider.getOptions('name');
setAutocomplete((prev) => ({ ...prev, spanNameOptions: res }));
},
500,
{ leading: true, trailing: true }
),
[languageProvider]
);
useEffect(() => {
const fetchAutocomplete = async () => {
try {
await languageProvider.start();
const serviceNameOptions = await languageProvider.getOptions('service.name');
const spanNameOptions = await languageProvider.getOptions('name');
setHasSyntaxLoaded(true);
setAutocomplete({ serviceNameOptions, spanNameOptions });
} catch (error) {
// Display message if Tempo is connected but search 404's
if (error?.status === 404) {
setError(error);
} else {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchAutocomplete();
}, [languageProvider, fetchServiceNameOptions, fetchSpanNameOptions]);
const onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
return await languageProvider.provideCompletionItems(typeahead);
};
const cleanText = (text: string) => {
const splittedText = text.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/g);
if (splittedText.length > 1) {
return splittedText[splittedText.length - 1];
}
return text;
};
const onKeyDown = (keyEvent: React.KeyboardEvent) => {
if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) {
onRunQuery();
}
};
return (
<>
<div className={styles.container}>
<InlineFieldRow>
<InlineField label="Service Name" labelWidth={14} grow>
<Select
menuShouldPortal
options={autocomplete.serviceNameOptions}
value={query.serviceName || ''}
onChange={(v) => {
onChange({
...query,
serviceName: v?.value || undefined,
});
}}
placeholder="Select a service"
onOpenMenu={fetchServiceNameOptions}
isClearable
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Span Name" labelWidth={14} grow>
<Select
menuShouldPortal
options={autocomplete.spanNameOptions}
value={query.spanName || ''}
onChange={(v) => {
onChange({
...query,
spanName: v?.value || undefined,
});
}}
placeholder="Select a span"
onOpenMenu={fetchSpanNameOptions}
isClearable
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in the logfmt format.">
<QueryField
additionalPlugins={plugins}
query={query.search}
onTypeahead={onTypeahead}
onBlur={onBlur}
onChange={(value) => {
onChange({
...query,
search: value,
});
}}
placeholder="http.status_code=200 error=true"
cleanText={cleanText}
onRunQuery={onRunQuery}
syntaxLoaded={hasSyntaxLoaded}
portalOrigin="tempo"
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Min Duration" invalid={!!inputErrors.minDuration} labelWidth={14} grow>
<Input
value={query.minDuration || ''}
placeholder={durationPlaceholder}
onBlur={() => {
if (query.minDuration && !isValidGoDuration(query.minDuration)) {
setInputErrors({ ...inputErrors, minDuration: true });
} else {
setInputErrors({ ...inputErrors, minDuration: false });
}
}}
onChange={(v) =>
onChange({
...query,
minDuration: v.currentTarget.value,
})
}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Max Duration" invalid={!!inputErrors.maxDuration} labelWidth={14} grow>
<Input
value={query.maxDuration || ''}
placeholder={durationPlaceholder}
onBlur={() => {
if (query.maxDuration && !isValidGoDuration(query.maxDuration)) {
setInputErrors({ ...inputErrors, maxDuration: true });
} else {
setInputErrors({ ...inputErrors, maxDuration: false });
}
}}
onChange={(v) =>
onChange({
...query,
maxDuration: v.currentTarget.value,
})
}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
label="Limit"
invalid={!!inputErrors.limit}
labelWidth={14}
grow
tooltip="Maximum numbers of returned results"
>
<Input
value={query.limit || ''}
type="number"
onChange={(v) => {
let limit = v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined;
if (limit && (!Number.isInteger(limit) || limit <= 0)) {
setInputErrors({ ...inputErrors, limit: true });
} else {
setInputErrors({ ...inputErrors, limit: false });
}
onChange({
...query,
limit: v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined,
});
}}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
</div>
{error ? (
<Alert title="Unable to connect to Tempo search" severity="info" className={styles.alert}>
Please ensure that Tempo is configured with search enabled. If you would like to hide this tab, you can
configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>.
</Alert>
) : null}
</>
);
};
export default NativeSearch;
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
max-width: 500px;
`,
alert: css`
max-width: 75ch;
margin-top: ${theme.spacing(2)};
`,
});

View File

@@ -0,0 +1,247 @@
import { css } from '@emotion/css';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Badge,
FileDropzone,
InlineField,
InlineFieldRow,
InlineLabel,
QueryField,
RadioButtonGroup,
Themeable2,
withTheme2,
} from '@grafana/ui';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings';
import React from 'react';
import { LokiQueryField } from '../../loki/components/LokiQueryField';
import { LokiQuery } from '../../loki/types';
import { TempoDatasource, TempoQuery, TempoQueryType } from '../datasource';
import LokiDatasource from '../../loki/datasource';
import { PrometheusDatasource } from '../../prometheus/datasource';
import useAsync from 'react-use/lib/useAsync';
import NativeSearch from './NativeSearch';
import { getDS } from './utils';
import { ServiceGraphSection } from './ServiceGraphSection';
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {}
const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceId';
interface State {
linkedDatasourceUid?: string;
linkedDatasource?: LokiDatasource;
serviceMapDatasourceUid?: string;
serviceMapDatasource?: PrometheusDatasource;
}
class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
state = {
linkedDatasourceUid: undefined,
linkedDatasource: undefined,
serviceMapDatasourceUid: undefined,
serviceMapDatasource: undefined,
};
constructor(props: Props) {
super(props);
}
async componentDidMount() {
const { datasource } = this.props;
// Find query field from linked datasource
const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {};
const linkedDatasourceUid = tracesToLogsOptions.datasourceUid;
const serviceMapDsUid = datasource.serviceMap?.datasourceUid;
// Check status of linked data sources so we can show warnings if needed.
const [logsDs, serviceMapDs] = await Promise.all([getDS(linkedDatasourceUid), getDS(serviceMapDsUid)]);
this.setState({
linkedDatasourceUid: linkedDatasourceUid,
linkedDatasource: logsDs as LokiDatasource,
serviceMapDatasourceUid: serviceMapDsUid,
serviceMapDatasource: serviceMapDs as PrometheusDatasource,
});
// Set initial query type to ensure traceID field appears
if (!this.props.query.queryType) {
this.props.onChange({
...this.props.query,
queryType: DEFAULT_QUERY_TYPE,
});
}
}
onChangeLinkedQuery = (value: LokiQuery) => {
const { query, onChange } = this.props;
onChange({
...query,
linkedQuery: { ...value, refId: 'linked' },
});
};
onRunLinkedQuery = () => {
this.props.onRunQuery();
};
render() {
const { query, onChange, datasource } = this.props;
// Find query field from linked datasource
const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {};
const logsDatasourceUid = tracesToLogsOptions.datasourceUid;
const graphDatasourceUid = datasource.serviceMap?.datasourceUid;
const queryTypeOptions: Array<SelectableValue<TempoQueryType>> = [
{ value: 'traceId', label: 'TraceID' },
{ value: 'upload', label: 'JSON file' },
];
if (config.featureToggles.tempoServiceGraph) {
queryTypeOptions.push({ value: 'serviceMap', label: 'Service Graph' });
}
if (config.featureToggles.tempoSearch && !datasource?.search?.hide) {
queryTypeOptions.unshift({ value: 'nativeSearch', label: 'Search - Beta' });
}
if (logsDatasourceUid && tracesToLogsOptions?.lokiSearch !== false) {
if (!config.featureToggles.tempoSearch) {
// Place at beginning as Search if no native search
queryTypeOptions.unshift({ value: 'search', label: 'Search' });
} else {
// Place at end as Loki Search if native search is enabled
queryTypeOptions.push({ value: 'search', label: 'Loki Search' });
}
}
return (
<>
<InlineFieldRow>
<InlineField label="Query type">
<RadioButtonGroup<TempoQueryType>
options={queryTypeOptions}
value={query.queryType}
onChange={(v) =>
onChange({
...query,
queryType: v,
})
}
size="md"
/>
</InlineField>
{query.queryType === 'nativeSearch' && (
<p>
<Badge icon="rocket" text="Beta" color="blue" />
&nbsp;Tempo search is currently in beta and is designed to return recent traces only. It ignores the time
range picker. We are actively working on full backend search. Look for improvements in the near future!
</p>
)}
</InlineFieldRow>
{query.queryType === 'search' && (
<SearchSection
linkedDatasourceUid={logsDatasourceUid}
query={query}
onRunQuery={this.onRunLinkedQuery}
onChange={this.onChangeLinkedQuery}
/>
)}
{query.queryType === 'nativeSearch' && (
<NativeSearch
datasource={this.props.datasource}
query={query}
onChange={onChange}
onBlur={this.props.onBlur}
onRunQuery={this.props.onRunQuery}
/>
)}
{query.queryType === 'upload' && (
<div className={css({ padding: this.props.theme.spacing(2) })}>
<FileDropzone
options={{ multiple: false }}
onLoad={(result) => {
this.props.datasource.uploadedJson = result;
this.props.onRunQuery();
}}
/>
</div>
)}
{query.queryType === 'traceId' && (
<InlineFieldRow>
<InlineField label="Trace ID" labelWidth={14} grow>
<QueryField
query={query.query}
onChange={(val) => {
onChange({
...query,
query: val,
queryType: 'traceId',
linkedQuery: undefined,
});
}}
onBlur={this.props.onBlur}
onRunQuery={this.props.onRunQuery}
placeholder={'Enter a Trace ID (run with Shift+Enter)'}
portalOrigin="tempo"
/>
</InlineField>
</InlineFieldRow>
)}
{query.queryType === 'serviceMap' && (
<ServiceGraphSection graphDatasourceUid={graphDatasourceUid} query={query} onChange={onChange} />
)}
</>
);
}
}
interface SearchSectionProps {
linkedDatasourceUid?: string;
onChange: (value: LokiQuery) => void;
onRunQuery: () => void;
query: TempoQuery;
}
function SearchSection({ linkedDatasourceUid, onChange, onRunQuery, query }: SearchSectionProps) {
const dsState = useAsync(() => getDS(linkedDatasourceUid), [linkedDatasourceUid]);
if (dsState.loading) {
return null;
}
const ds = dsState.value as LokiDatasource;
if (ds) {
return (
<>
<InlineLabel>Tempo uses {ds.name} to find traces.</InlineLabel>
<LokiQueryField
datasource={ds}
onChange={onChange}
onRunQuery={onRunQuery}
query={query.linkedQuery ?? ({ refId: 'linked' } as any)}
history={[]}
/>
</>
);
}
if (!linkedDatasourceUid) {
return <div className="text-warning">Please set up a Traces-to-logs datasource in the datasource settings.</div>;
}
if (linkedDatasourceUid && !ds) {
return (
<div className="text-warning">
Traces-to-logs datasource is configured but the data source no longer exists. Please configure existing data
source to use the search.
</div>
);
}
return null;
}
export const TempoQueryField = withTheme2(TempoQueryFieldComponent);

View File

@@ -0,0 +1,87 @@
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { getDS } from './utils';
import { InlineField, InlineFieldRow } from '@grafana/ui';
import { AdHocVariableFilter } from '../../../../features/variables/types';
import { TempoQuery } from '../datasource';
import { AdHocFilter } from '../../../../features/variables/adhoc/picker/AdHocFilter';
import { PrometheusDatasource } from '../../prometheus/datasource';
export function ServiceGraphSection({
graphDatasourceUid,
query,
onChange,
}: {
graphDatasourceUid?: string;
query: TempoQuery;
onChange: (value: TempoQuery) => void;
}) {
const dsState = useAsync(() => getDS(graphDatasourceUid), [graphDatasourceUid]);
if (dsState.loading) {
return null;
}
const ds = dsState.value as PrometheusDatasource;
if (!graphDatasourceUid) {
return <div className="text-warning">Please set up a service graph datasource in the datasource settings.</div>;
}
if (graphDatasourceUid && !ds) {
return (
<div className="text-warning">
Service graph datasource is configured but the data source no longer exists. Please configure existing data
source to use the service graph functionality.
</div>
);
}
const filters = queryToFilter(query.serviceMapQuery || '');
return (
<div>
<InlineFieldRow>
<InlineField label="Filter" labelWidth={14} grow>
<AdHocFilter
datasource={{ uid: graphDatasourceUid }}
filters={filters}
addFilter={(filter: AdHocVariableFilter) => {
onChange({
...query,
serviceMapQuery: filtersToQuery([...filters, filter]),
});
}}
removeFilter={(index: number) => {
const newFilters = [...filters];
newFilters.splice(index, 1);
onChange({ ...query, serviceMapQuery: filtersToQuery(newFilters) });
}}
changeFilter={(index: number, filter: AdHocVariableFilter) => {
const newFilters = [...filters];
newFilters.splice(index, 1, filter);
onChange({ ...query, serviceMapQuery: filtersToQuery(newFilters) });
}}
/>
</InlineField>
</InlineFieldRow>
</div>
);
}
function queryToFilter(query: string): AdHocVariableFilter[] {
let match;
let filters: AdHocVariableFilter[] = [];
const re = /([\w_]+)(=|!=|<|>|=~|!~)"(.*?)"/g;
while ((match = re.exec(query)) !== null) {
filters.push({
key: match[1],
operator: match[2],
value: match[3],
condition: '',
});
}
return filters;
}
function filtersToQuery(filters: AdHocVariableFilter[]): string {
return `{${filters.map((f) => `${f.key}${f.operator}"${f.value}"`).join(',')}}`;
}

View File

@@ -0,0 +1,16 @@
import { DataSourceApi } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
export async function getDS(uid?: string): Promise<DataSourceApi | undefined> {
if (!uid) {
return undefined;
}
const dsSrv = getDataSourceSrv();
try {
return await dsSrv.get(uid);
} catch (error) {
console.error('Failed to load data source', error);
return undefined;
}
}