mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tempo: Search using TraceQL improvements (#64616)
* Added scope to filter * Intrinsic fields don't have scope * Consistent plus button placement next to the last tag. Changed All Scopes to "unscoped" * Added validation to duration fields * Disable options load when dropdown is opened * Use focused list of operators when all values are of type string or int/float * Fixed and added tests * Fix another test * Better way to prevent duplicate and redundant backend requests when a filter updates
This commit is contained in:
parent
6093e45178
commit
bfb0dde4a8
@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
package dataquery
|
package dataquery
|
||||||
|
|
||||||
|
// Defines values for TempoQueryFiltersScope.
|
||||||
|
const (
|
||||||
|
TempoQueryFiltersScopeResource TempoQueryFiltersScope = "resource"
|
||||||
|
TempoQueryFiltersScopeSpan TempoQueryFiltersScope = "span"
|
||||||
|
TempoQueryFiltersScopeUnscoped TempoQueryFiltersScope = "unscoped"
|
||||||
|
)
|
||||||
|
|
||||||
// Defines values for TempoQueryFiltersType.
|
// Defines values for TempoQueryFiltersType.
|
||||||
const (
|
const (
|
||||||
TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic"
|
TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic"
|
||||||
@ -26,6 +33,13 @@ const (
|
|||||||
TempoQueryTypeUpload TempoQueryType = "upload"
|
TempoQueryTypeUpload TempoQueryType = "upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Defines values for TraceqlFilterScope.
|
||||||
|
const (
|
||||||
|
TraceqlFilterScopeResource TraceqlFilterScope = "resource"
|
||||||
|
TraceqlFilterScopeSpan TraceqlFilterScope = "span"
|
||||||
|
TraceqlFilterScopeUnscoped TraceqlFilterScope = "unscoped"
|
||||||
|
)
|
||||||
|
|
||||||
// Defines values for TraceqlFilterType.
|
// Defines values for TraceqlFilterType.
|
||||||
const (
|
const (
|
||||||
TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic"
|
TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic"
|
||||||
@ -38,6 +52,13 @@ const (
|
|||||||
TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static"
|
TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Defines values for TraceqlSearchScope.
|
||||||
|
const (
|
||||||
|
TraceqlSearchScopeResource TraceqlSearchScope = "resource"
|
||||||
|
TraceqlSearchScopeSpan TraceqlSearchScope = "span"
|
||||||
|
TraceqlSearchScopeUnscoped TraceqlSearchScope = "unscoped"
|
||||||
|
)
|
||||||
|
|
||||||
// TempoDataQuery defines model for TempoDataQuery.
|
// TempoDataQuery defines model for TempoDataQuery.
|
||||||
type TempoDataQuery = map[string]interface{}
|
type TempoDataQuery = map[string]interface{}
|
||||||
|
|
||||||
@ -55,6 +76,9 @@ type TempoQuery struct {
|
|||||||
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
Operator *string `json:"operator,omitempty"`
|
Operator *string `json:"operator,omitempty"`
|
||||||
|
|
||||||
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
Scope *TempoQueryFiltersScope `json:"scope,omitempty"`
|
||||||
|
|
||||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
Tag *string `json:"tag,omitempty"`
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
|
||||||
@ -107,6 +131,9 @@ type TempoQuery struct {
|
|||||||
SpanName *string `json:"spanName,omitempty"`
|
SpanName *string `json:"spanName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
type TempoQueryFiltersScope string
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
||||||
type TempoQueryFiltersType string
|
type TempoQueryFiltersType string
|
||||||
|
|
||||||
@ -121,6 +148,9 @@ type TraceqlFilter struct {
|
|||||||
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
Operator *string `json:"operator,omitempty"`
|
Operator *string `json:"operator,omitempty"`
|
||||||
|
|
||||||
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
Scope *TraceqlFilterScope `json:"scope,omitempty"`
|
||||||
|
|
||||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
Tag *string `json:"tag,omitempty"`
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
|
||||||
@ -134,8 +164,14 @@ type TraceqlFilter struct {
|
|||||||
ValueType *string `json:"valueType,omitempty"`
|
ValueType *string `json:"valueType,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
type TraceqlFilterScope string
|
||||||
|
|
||||||
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
// The type of the filter, can either be static (pre defined in the UI) or dynamic
|
||||||
type TraceqlFilterType string
|
type TraceqlFilterType string
|
||||||
|
|
||||||
// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user
|
// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
type TraceqlSearchFilterType string
|
type TraceqlSearchFilterType string
|
||||||
|
|
||||||
|
// TraceqlSearchScope defines model for TraceqlSearchScope.
|
||||||
|
type TraceqlSearchScope string
|
||||||
|
@ -12,7 +12,15 @@ interface Props {
|
|||||||
isTagsLoading?: boolean;
|
isTagsLoading?: boolean;
|
||||||
operators: string[];
|
operators: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validationRegex = /^\d+(?:\.\d)?\d*(?:ms|s|ns)$/;
|
||||||
|
|
||||||
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
||||||
|
let invalid = false;
|
||||||
|
if (typeof filter.value === 'string') {
|
||||||
|
invalid = filter.value ? !validationRegex.test(filter.value.concat('')) : false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup spacing={'none'}>
|
<HorizontalGroup spacing={'none'}>
|
||||||
<Select
|
<Select
|
||||||
@ -34,6 +42,7 @@ const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="e.g. 100ms, 1.2s"
|
placeholder="e.g. 100ms, 1.2s"
|
||||||
aria-label={`select ${filter.id} value`}
|
aria-label={`select ${filter.id} value`}
|
||||||
|
invalid={invalid}
|
||||||
width={18}
|
width={18}
|
||||||
/>
|
/>
|
||||||
</HorizontalGroup>
|
</HorizontalGroup>
|
||||||
|
@ -73,10 +73,10 @@ describe('SearchField', () => {
|
|||||||
if (select) {
|
if (select) {
|
||||||
await user.click(select);
|
await user.click(select);
|
||||||
jest.advanceTimersByTime(1000);
|
jest.advanceTimersByTime(1000);
|
||||||
const largerThanOp = await screen.findByText('>');
|
const largerThanOp = await screen.findByText('!=');
|
||||||
await user.click(largerThanOp);
|
await user.click(largerThanOp);
|
||||||
|
|
||||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '>' });
|
expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '!=' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,19 +1,26 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { AccessoryButton } from '@grafana/experimental';
|
import { AccessoryButton } from '@grafana/experimental';
|
||||||
import { FetchError, isFetchError } from '@grafana/runtime';
|
import { FetchError, isFetchError } from '@grafana/runtime';
|
||||||
import { Select, HorizontalGroup } from '@grafana/ui';
|
import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { createErrorNotification } from '../../../../core/copy/appNotification';
|
import { createErrorNotification } from '../../../../core/copy/appNotification';
|
||||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
import { dispatch } from '../../../../store/store';
|
import { dispatch } from '../../../../store/store';
|
||||||
import { TraceqlFilter } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
import TempoLanguageProvider from '../language_provider';
|
import TempoLanguageProvider from '../language_provider';
|
||||||
import { operators as allOperators } from '../traceql/traceql';
|
import { operators as allOperators, stringOperators, numberOperators } from '../traceql/traceql';
|
||||||
|
|
||||||
import { operatorSelectableValue } from './utils';
|
import { operatorSelectableValue, scopeHelper } from './utils';
|
||||||
|
|
||||||
|
const getStyles = () => ({
|
||||||
|
dropdown: css`
|
||||||
|
box-shadow: none;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: TraceqlFilter;
|
filter: TraceqlFilter;
|
||||||
@ -23,21 +30,13 @@ interface Props {
|
|||||||
setError: (error: FetchError) => void;
|
setError: (error: FetchError) => void;
|
||||||
isTagsLoading?: boolean;
|
isTagsLoading?: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
operators?: string[];
|
|
||||||
}
|
}
|
||||||
const SearchField = ({
|
const SearchField = ({ filter, datasource, updateFilter, deleteFilter, isTagsLoading, tags, setError }: Props) => {
|
||||||
filter,
|
const styles = useStyles2(getStyles);
|
||||||
datasource,
|
|
||||||
updateFilter,
|
|
||||||
deleteFilter,
|
|
||||||
isTagsLoading,
|
|
||||||
tags,
|
|
||||||
setError,
|
|
||||||
operators,
|
|
||||||
}: Props) => {
|
|
||||||
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
|
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
|
||||||
const [isLoadingValues, setIsLoadingValues] = useState(false);
|
const [isLoadingValues, setIsLoadingValues] = useState(false);
|
||||||
const [options, setOptions] = useState<Array<SelectableValue<string>>>([]);
|
const [options, setOptions] = useState<Array<SelectableValue<string>>>([]);
|
||||||
|
const [scopedTag, setScopedTag] = useState(scopeHelper(filter) + filter.tag);
|
||||||
// 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
|
||||||
@ -58,53 +57,69 @@ const SearchField = ({
|
|||||||
setPrevValue(filter.value);
|
setPrevValue(filter.value);
|
||||||
}, [filter.value]);
|
}, [filter.value]);
|
||||||
|
|
||||||
const loadOptions = useCallback(
|
useEffect(() => {
|
||||||
async (name: string) => {
|
const newScopedTag = scopeHelper(filter) + filter.tag;
|
||||||
setIsLoadingValues(true);
|
if (newScopedTag !== scopedTag) {
|
||||||
|
setScopedTag(newScopedTag);
|
||||||
|
}
|
||||||
|
}, [filter, scopedTag]);
|
||||||
|
|
||||||
try {
|
const updateOptions = useCallback(async () => {
|
||||||
const options = await languageProvider.getOptionsV2(name);
|
try {
|
||||||
return options;
|
setIsLoadingValues(true);
|
||||||
} catch (error) {
|
setOptions(await languageProvider.getOptionsV2(scopedTag));
|
||||||
if (isFetchError(error) && error?.status === 404) {
|
} catch (error) {
|
||||||
setError(error);
|
// Display message if Tempo is connected but search 404's
|
||||||
} else if (error instanceof Error) {
|
if (isFetchError(error) && error?.status === 404) {
|
||||||
dispatch(notifyApp(createErrorNotification('Error', error)));
|
setError(error);
|
||||||
}
|
} else if (error instanceof Error) {
|
||||||
return [];
|
dispatch(notifyApp(createErrorNotification('Error', error)));
|
||||||
} finally {
|
|
||||||
setIsLoadingValues(false);
|
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
[setError, languageProvider]
|
setIsLoadingValues(false);
|
||||||
);
|
}
|
||||||
|
}, [scopedTag, languageProvider, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOptions = async () => {
|
updateOptions();
|
||||||
try {
|
}, [updateOptions]);
|
||||||
if (filter.tag) {
|
|
||||||
setOptions(await loadOptions(filter.tag));
|
const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t }));
|
||||||
}
|
|
||||||
} catch (error) {
|
// If all values have type string or int/float use a focused list of operators instead of all operators
|
||||||
// Display message if Tempo is connected but search 404's
|
const optionsOfFirstType = options.filter((o) => o.type === options[0]?.type);
|
||||||
if (isFetchError(error) && error?.status === 404) {
|
const uniqueOptionType = options.length === optionsOfFirstType.length ? options[0]?.type : undefined;
|
||||||
setError(error);
|
let operatorList = allOperators;
|
||||||
} else if (error instanceof Error) {
|
switch (uniqueOptionType) {
|
||||||
dispatch(notifyApp(createErrorNotification('Error', error)));
|
case 'string':
|
||||||
}
|
operatorList = stringOperators;
|
||||||
}
|
break;
|
||||||
};
|
case 'int':
|
||||||
fetchOptions();
|
case 'float':
|
||||||
}, [languageProvider, loadOptions, setError, filter.tag]);
|
operatorList = numberOperators;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup spacing={'none'}>
|
<HorizontalGroup spacing={'none'} width={'auto'}>
|
||||||
{filter.type === 'dynamic' && (
|
{filter.type === 'dynamic' && (
|
||||||
<Select
|
<Select
|
||||||
|
className={styles.dropdown}
|
||||||
|
inputId={`${filter.id}-scope`}
|
||||||
|
options={scopeOptions}
|
||||||
|
value={filter.scope}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateFilter({ ...filter, scope: v?.value });
|
||||||
|
}}
|
||||||
|
placeholder="Select scope"
|
||||||
|
aria-label={`select ${filter.id} scope`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filter.type === 'dynamic' && (
|
||||||
|
<Select
|
||||||
|
className={styles.dropdown}
|
||||||
inputId={`${filter.id}-tag`}
|
inputId={`${filter.id}-tag`}
|
||||||
isLoading={isTagsLoading}
|
isLoading={isTagsLoading}
|
||||||
options={tags.map((t) => ({ label: t, value: t }))}
|
options={tags.map((t) => ({ label: t, value: t }))}
|
||||||
onOpenMenu={() => tags}
|
|
||||||
value={filter.tag}
|
value={filter.tag}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
updateFilter({ ...filter, tag: v?.value });
|
updateFilter({ ...filter, tag: v?.value });
|
||||||
@ -116,8 +131,9 @@ const SearchField = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
|
className={styles.dropdown}
|
||||||
inputId={`${filter.id}-operator`}
|
inputId={`${filter.id}-operator`}
|
||||||
options={(operators || allOperators).map(operatorSelectableValue)}
|
options={operatorList.map(operatorSelectableValue)}
|
||||||
value={filter.operator}
|
value={filter.operator}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
updateFilter({ ...filter, operator: v?.value });
|
updateFilter({ ...filter, operator: v?.value });
|
||||||
@ -128,14 +144,10 @@ const SearchField = ({
|
|||||||
width={8}
|
width={8}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
className={styles.dropdown}
|
||||||
inputId={`${filter.id}-value`}
|
inputId={`${filter.id}-value`}
|
||||||
isLoading={isLoadingValues}
|
isLoading={isLoadingValues}
|
||||||
options={options}
|
options={options}
|
||||||
onOpenMenu={() => {
|
|
||||||
if (filter.tag) {
|
|
||||||
loadOptions(filter.tag);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={filter.value}
|
value={filter.value}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
|
@ -1,15 +1,29 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { AccessoryButton } from '@grafana/experimental';
|
import { AccessoryButton } from '@grafana/experimental';
|
||||||
import { FetchError } from '@grafana/runtime';
|
import { FetchError } from '@grafana/runtime';
|
||||||
import { HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { TraceqlFilter } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
|
|
||||||
import SearchField from './SearchField';
|
import SearchField from './SearchField';
|
||||||
|
|
||||||
|
const getStyles = () => ({
|
||||||
|
vertical: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
`,
|
||||||
|
horizontal: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
updateFilter: (f: TraceqlFilter) => void;
|
updateFilter: (f: TraceqlFilter) => void;
|
||||||
deleteFilter: (f: TraceqlFilter) => void;
|
deleteFilter: (f: TraceqlFilter) => void;
|
||||||
@ -20,9 +34,10 @@ interface Props {
|
|||||||
isTagsLoading: boolean;
|
isTagsLoading: boolean;
|
||||||
}
|
}
|
||||||
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
|
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
const generateId = () => uuidv4().slice(0, 8);
|
const generateId = () => uuidv4().slice(0, 8);
|
||||||
const handleOnAdd = useCallback(
|
const handleOnAdd = useCallback(
|
||||||
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=' }),
|
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=', scope: TraceqlSearchScope.Span }),
|
||||||
[updateFilter]
|
[updateFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -32,26 +47,27 @@ const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError,
|
|||||||
}
|
}
|
||||||
}, [filters, handleOnAdd]);
|
}, [filters, handleOnAdd]);
|
||||||
|
|
||||||
|
const dynamicFilters = filters?.filter((f) => f.type === 'dynamic');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup spacing={'md'} align={'flex-start'}>
|
<div className={styles.vertical}>
|
||||||
<VerticalGroup spacing={'xs'}>
|
{dynamicFilters?.map((f, i) => (
|
||||||
{filters
|
<div className={styles.horizontal} key={f.id}>
|
||||||
?.filter((f) => f.type === 'dynamic')
|
<SearchField
|
||||||
.map((f) => (
|
filter={f}
|
||||||
<SearchField
|
datasource={datasource}
|
||||||
filter={f}
|
setError={setError}
|
||||||
key={f.id}
|
updateFilter={updateFilter}
|
||||||
datasource={datasource}
|
tags={tags}
|
||||||
setError={setError}
|
isTagsLoading={isTagsLoading}
|
||||||
updateFilter={updateFilter}
|
deleteFilter={deleteFilter}
|
||||||
tags={tags}
|
/>
|
||||||
isTagsLoading={isTagsLoading}
|
{i === dynamicFilters.length - 1 && (
|
||||||
deleteFilter={deleteFilter}
|
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
|
||||||
/>
|
)}
|
||||||
))}
|
</div>
|
||||||
</VerticalGroup>
|
))}
|
||||||
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
|
</div>
|
||||||
</HorizontalGroup>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { TraceqlSearchScope } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
import { TempoQuery } from '../types';
|
import { TempoQuery } from '../types';
|
||||||
|
|
||||||
@ -101,7 +102,8 @@ describe('TraceQLSearch', () => {
|
|||||||
expect(nameFilter).not.toBeNull();
|
expect(nameFilter).not.toBeNull();
|
||||||
expect(nameFilter?.operator).toBe('=');
|
expect(nameFilter?.operator).toBe('=');
|
||||||
expect(nameFilter?.value).toStrictEqual(['customer']);
|
expect(nameFilter?.value).toStrictEqual(['customer']);
|
||||||
expect(nameFilter?.tag).toBe('.service.name');
|
expect(nameFilter?.tag).toBe('service.name');
|
||||||
|
expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { EditorRow } from '@grafana/experimental';
|
import { EditorRow } from '@grafana/experimental';
|
||||||
@ -10,7 +10,7 @@ import { createErrorNotification } from '../../../../core/copy/appNotification';
|
|||||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
import { dispatch } from '../../../../store/store';
|
import { dispatch } from '../../../../store/store';
|
||||||
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
||||||
import { TraceqlFilter } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
import { TempoDatasource } from '../datasource';
|
import { TempoDatasource } from '../datasource';
|
||||||
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
||||||
import { CompletionProvider } from '../traceql/autocomplete';
|
import { CompletionProvider } from '../traceql/autocomplete';
|
||||||
@ -73,8 +73,7 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
if (!tags.find((t) => t === 'status')) {
|
if (!tags.find((t) => t === 'status')) {
|
||||||
tags.push('status');
|
tags.push('status');
|
||||||
}
|
}
|
||||||
const tagsWithDot = tags.sort().map((t) => `.${t}`);
|
setTags(tags);
|
||||||
setTags(tagsWithDot);
|
|
||||||
setIsTagsLoading(false);
|
setIsTagsLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -96,15 +95,15 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
findFilter('service-name') || {
|
findFilter('service-name') || {
|
||||||
id: 'service-name',
|
id: 'service-name',
|
||||||
type: 'static',
|
type: 'static',
|
||||||
tag: '.service.name',
|
tag: 'service.name',
|
||||||
operator: '=',
|
operator: '=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
datasource={datasource}
|
datasource={datasource}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
updateFilter={updateFilter}
|
updateFilter={updateFilter}
|
||||||
tags={[]}
|
tags={[]}
|
||||||
operators={['=', '!=', '=~']}
|
|
||||||
/>
|
/>
|
||||||
</InlineSearchField>
|
</InlineSearchField>
|
||||||
<InlineSearchField label={'Span Name'}>
|
<InlineSearchField label={'Span Name'}>
|
||||||
@ -114,7 +113,6 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
|
|||||||
setError={setError}
|
setError={setError}
|
||||||
updateFilter={updateFilter}
|
updateFilter={updateFilter}
|
||||||
tags={[]}
|
tags={[]}
|
||||||
operators={['=', '!=', '=~']}
|
|
||||||
/>
|
/>
|
||||||
</InlineSearchField>
|
</InlineSearchField>
|
||||||
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
|
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { TraceqlSearchScope } from '../dataquery.gen';
|
||||||
|
|
||||||
import { generateQueryFromFilters } from './utils';
|
import { generateQueryFromFilters } from './utils';
|
||||||
|
|
||||||
describe('generateQueryFromFilters generates the correct query for', () => {
|
describe('generateQueryFromFilters generates the correct query for', () => {
|
||||||
@ -20,7 +22,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
it('a field with tag, operator and tag', () => {
|
it('a field with tag, operator and tag', () => {
|
||||||
expect(
|
expect(
|
||||||
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
|
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
|
||||||
).toBe('{footag="foovalue"}');
|
).toBe('{.footag="foovalue"}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a field with valueType as integer', () => {
|
it('a field with valueType as integer', () => {
|
||||||
@ -28,7 +30,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
generateQueryFromFilters([
|
generateQueryFromFilters([
|
||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
|
||||||
])
|
])
|
||||||
).toBe('{footag>1234}');
|
).toBe('{.footag>1234}');
|
||||||
});
|
});
|
||||||
it('two fields with everything filled in', () => {
|
it('two fields with everything filled in', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -36,7 +38,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
{ id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
|
{ id: 'bar', type: 'dynamic', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
|
||||||
])
|
])
|
||||||
).toBe('{footag>=1234 && bartag="barvalue"}');
|
).toBe('{.footag>=1234 && .bartag="barvalue"}');
|
||||||
});
|
});
|
||||||
it('two fields but one is missing a value', () => {
|
it('two fields but one is missing a value', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -44,7 +46,7 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
|
||||||
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
|
||||||
])
|
])
|
||||||
).toBe('{footag>=1234}');
|
).toBe('{.footag>=1234}');
|
||||||
});
|
});
|
||||||
it('two fields but one is missing a value and the other a tag', () => {
|
it('two fields but one is missing a value and the other a tag', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -54,4 +56,49 @@ describe('generateQueryFromFilters generates the correct query for', () => {
|
|||||||
])
|
])
|
||||||
).toBe('{}');
|
).toBe('{}');
|
||||||
});
|
});
|
||||||
|
it('scope is unscoped', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{
|
||||||
|
id: 'foo',
|
||||||
|
type: 'static',
|
||||||
|
tag: 'footag',
|
||||||
|
value: '1234',
|
||||||
|
operator: '>=',
|
||||||
|
scope: TraceqlSearchScope.Unscoped,
|
||||||
|
valueType: 'integer',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe('{.footag>=1234}');
|
||||||
|
});
|
||||||
|
it('scope is span', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{
|
||||||
|
id: 'foo',
|
||||||
|
type: 'static',
|
||||||
|
tag: 'footag',
|
||||||
|
value: '1234',
|
||||||
|
operator: '>=',
|
||||||
|
scope: TraceqlSearchScope.Span,
|
||||||
|
valueType: 'integer',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe('{span.footag>=1234}');
|
||||||
|
});
|
||||||
|
it('scope is resource', () => {
|
||||||
|
expect(
|
||||||
|
generateQueryFromFilters([
|
||||||
|
{
|
||||||
|
id: 'foo',
|
||||||
|
type: 'static',
|
||||||
|
tag: 'footag',
|
||||||
|
value: '1234',
|
||||||
|
operator: '>=',
|
||||||
|
scope: TraceqlSearchScope.Resource,
|
||||||
|
valueType: 'integer',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe('{resource.footag>=1234}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
import { TraceqlFilter } from '../dataquery.gen';
|
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||||
|
import { CompletionProvider } from '../traceql/autocomplete';
|
||||||
|
|
||||||
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => {
|
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => {
|
||||||
return `{${filters
|
return `{${filters
|
||||||
.filter((f) => f.tag && f.operator && f.value?.length)
|
.filter((f) => f.tag && f.operator && f.value?.length)
|
||||||
.map((f) => `${f.tag}${f.operator}${valueHelper(f)}`)
|
.map((f) => `${scopeHelper(f)}${f.tag}${f.operator}${valueHelper(f)}`)
|
||||||
.join(' && ')}}`;
|
.join(' && ')}}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,6 +19,15 @@ const valueHelper = (f: TraceqlFilter) => {
|
|||||||
}
|
}
|
||||||
return f.value;
|
return f.value;
|
||||||
};
|
};
|
||||||
|
export const scopeHelper = (f: TraceqlFilter) => {
|
||||||
|
// Intrinsic fields don't have a scope
|
||||||
|
if (CompletionProvider.intrinsics.find((t) => t === f.tag)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(f.scope === TraceqlSearchScope.Resource || f.scope === TraceqlSearchScope.Span ? f.scope?.toLowerCase() : '') + '.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function replaceAt<T>(array: T[], index: number, value: T) {
|
export function replaceAt<T>(array: T[], index: number, value: T) {
|
||||||
const ret = array.slice(0);
|
const ret = array.slice(0);
|
||||||
|
@ -54,7 +54,8 @@ composableKinds: DataQuery: {
|
|||||||
#TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type")
|
#TempoQueryType: "traceql" | "traceqlSearch" | "search" | "serviceMap" | "upload" | "nativeSearch" | "clear" @cuetsy(kind="type")
|
||||||
|
|
||||||
// static fields are pre-set in the UI, dynamic fields are added by the user
|
// static fields are pre-set in the UI, dynamic fields are added by the user
|
||||||
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
|
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
|
||||||
|
#TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum")
|
||||||
#TraceqlFilter: {
|
#TraceqlFilter: {
|
||||||
// Uniquely identify the filter, will not be used in the query generation
|
// Uniquely identify the filter, will not be used in the query generation
|
||||||
id: string
|
id: string
|
||||||
@ -68,6 +69,8 @@ composableKinds: DataQuery: {
|
|||||||
value?: string | [...string]
|
value?: string | [...string]
|
||||||
// The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query
|
// The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query
|
||||||
valueType?: string
|
valueType?: string
|
||||||
|
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
scope?: #TraceqlSearchScope
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -62,6 +62,12 @@ export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceM
|
|||||||
*/
|
*/
|
||||||
export type TraceqlSearchFilterType = ('static' | 'dynamic');
|
export type TraceqlSearchFilterType = ('static' | 'dynamic');
|
||||||
|
|
||||||
|
export enum TraceqlSearchScope {
|
||||||
|
Resource = 'resource',
|
||||||
|
Span = 'span',
|
||||||
|
Unscoped = 'unscoped',
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraceqlFilter {
|
export interface TraceqlFilter {
|
||||||
/**
|
/**
|
||||||
* Uniquely identify the filter, will not be used in the query generation
|
* Uniquely identify the filter, will not be used in the query generation
|
||||||
@ -71,6 +77,10 @@ export interface TraceqlFilter {
|
|||||||
* The operator that connects the tag to the value, for example: =, >, !=, =~
|
* The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||||
*/
|
*/
|
||||||
operator?: string;
|
operator?: string;
|
||||||
|
/**
|
||||||
|
* The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||||
|
*/
|
||||||
|
scope?: TraceqlSearchScope;
|
||||||
/**
|
/**
|
||||||
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||||
*/
|
*/
|
||||||
|
@ -23,6 +23,8 @@ export const languageConfiguration = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~'];
|
export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~'];
|
||||||
|
export const stringOperators = ['=', '!=', '=~'];
|
||||||
|
export const numberOperators = ['=', '!=', '>', '<', '>=', '<='];
|
||||||
|
|
||||||
const intrinsics = ['duration', 'name', 'status', 'parent'];
|
const intrinsics = ['duration', 'name', 'status', 'parent'];
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user