Tempo: New Search UI using TraceQL (#63808)

* WIP of creating new components to support the Search tab using TraceQL

* Search fields now require an ID. Added duration fields to new Search UI

* Distinguish static from dynamic fields. Added dynamic tags input

* Moved new search behind traceqlSearch feature flag. Added handling of different types of values to accurately wrap them in quotes when generating query.

* Hold search state in TempoQuery to leverage state in URL. Moved types to schema file

* Use a read only monaco editor to render a syntax highlighted generated query. Added tooltip to duration. Added query options section

* Support multiple values using the regex operator and multi input

* Delete dynamic filters

* Automatically select the regex op when multiple values are selected. Revert to previous operator when only one value is selected

* Added tests for SearchField component

* Added tests for the TraceQLSearch component

* Added tests for function that generates the query

* Fix merge conflicts

* Update test

* Replace Search tab when traceqlSearch feature flag is enabled. Limit operators for both name fields to =,!=,=~

* Disable clear button for values

* Changed delete and add buttons to AccessoryButton. Added descriptions to operators

* Remove duplicate test

* Added a prismjs grammar for traceql. Replaced read only query editor with syntax highlighted query. Removed spaces between tag operator and value when generating query.

* Fix support for custom values when isMulti is enabled in Select

* Use toOption function
This commit is contained in:
Andre Pereira 2023-03-06 16:31:08 +00:00 committed by GitHub
parent 5db0d14606
commit fd37ff29b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1171 additions and 74 deletions

View File

@ -13,36 +13,7 @@ title: TempoDataQuery kind
It extends [DataQuery](#dataquery).
| Property | Type | Required | Description |
|-------------------|---------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `query` | string | **Yes** | TraceQL query or trace ID |
| `refId` | string | **Yes** | *(Inherited from [DataQuery](#dataquery))*<br/>A - Z |
| `datasource` | | No | *(Inherited from [DataQuery](#dataquery))*<br/>For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef &#124; null |
| `hide` | boolean | No | *(Inherited from [DataQuery](#dataquery))*<br/>true if query is disabled (ie should not be returned to the dashboard) |
| `key` | string | No | *(Inherited from [DataQuery](#dataquery))*<br/>Unique, guid like, string used in explore mode |
| `limit` | integer | No | Defines the maximum number of traces that are returned from Tempo |
| `maxDuration` | string | No | Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms |
| `minDuration` | string | No | Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms |
| `queryType` | string | No | *(Inherited from [DataQuery](#dataquery))*<br/>Specify the query flavor<br/>TODO make this required and give it a default |
| `search` | string | No | Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true |
| `serviceMapQuery` | string | No | Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"} |
| `serviceName` | string | No | Query traces by service name |
| `spanName` | string | No | Query traces by span name |
### DataQuery
These are the common properties available to all queries in all datasources.
Specific implementations will *extend* this interface, adding the required
properties for the given context.
| Property | Type | Required | Description |
|--------------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `refId` | string | **Yes** | A - Z |
| `datasource` | | No | For mixed data sources the selected datasource is on the query level.<br/>For non mixed scenarios this is undefined.<br/>TODO find a better way to do this ^ that's friendly to schema<br/>TODO this shouldn't be unknown but DataSourceRef &#124; null |
| `hide` | boolean | No | true if query is disabled (ie should not be returned to the dashboard) |
| `key` | string | No | Unique, guid like, string used in explore mode |
| `queryType` | string | No | Specify the query flavor<br/>TODO make this required and give it a default |
| Property | Type | Required | Description |
|----------|------|----------|-------------|

View File

@ -91,6 +91,7 @@ Alpha features might be changed or removed without prior notice.
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals |
| `individualCookiePreferences` | Support overriding cookie preferences per user |
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries |
## Development feature toggles

View File

@ -80,4 +80,5 @@ export interface FeatureToggles {
lokiQuerySplitting?: boolean;
individualCookiePreferences?: boolean;
drawerDataSourcePicker?: boolean;
traceqlSearch?: boolean;
}

View File

@ -4,7 +4,7 @@ import { default as ReactAsyncSelect } from 'react-select/async';
import { default as AsyncCreatable } from 'react-select/async-creatable';
import Creatable from 'react-select/creatable';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, toOption } from '@grafana/data';
import { useTheme2 } from '../../themes';
import { Icon } from '../Icon/Icon';
@ -190,8 +190,16 @@ export function SelectBase<T>({
// If option is passed as a plain value (value property from SelectableValue property)
// we are selecting the corresponding value from the options
if (isMulti && value && Array.isArray(value) && !loadOptions) {
// @ts-ignore
selectedValue = value.map((v) => findSelectedValue(v.value ?? v, options));
selectedValue = value.map((v) => {
// @ts-ignore
const selectableValue = findSelectedValue(v.value ?? v, options);
// If the select allows custom values there likely won't be a selectableValue in options
// so we must return a new selectableValue
if (!allowCustomValue || selectableValue) {
return selectableValue;
}
return typeof v === 'string' ? toOption(v) : v;
});
} else if (loadOptions) {
const hasValue = defaultValue || value;
selectedValue = hasValue ? [hasValue] : [];

View File

@ -362,5 +362,11 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
{
Name: "traceqlSearch",
Description: "Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries",
State: FeatureStateAlpha,
FrontendOnly: true,
},
}
)

View File

@ -262,4 +262,8 @@ const (
// FlagDrawerDataSourcePicker
// Changes the user experience for data source selection to a drawer.
FlagDrawerDataSourcePicker = "drawerDataSourcePicker"
// FlagTraceqlSearch
// Enables the &#39;TraceQL Search&#39; tab for the Tempo datasource which provides a UI to generate TraceQL queries
FlagTraceqlSearch = "traceqlSearch"
)

View File

@ -9,23 +9,64 @@
package dataquery
// Defines values for TempoQueryFiltersType.
const (
TempoQueryFiltersTypeDynamic TempoQueryFiltersType = "dynamic"
TempoQueryFiltersTypeStatic TempoQueryFiltersType = "static"
)
// Defines values for TempoQueryType.
const (
TempoQueryTypeClear TempoQueryType = "clear"
TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch"
TempoQueryTypeSearch TempoQueryType = "search"
TempoQueryTypeServiceMap TempoQueryType = "serviceMap"
TempoQueryTypeTraceql TempoQueryType = "traceql"
TempoQueryTypeUpload TempoQueryType = "upload"
TempoQueryTypeClear TempoQueryType = "clear"
TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch"
TempoQueryTypeSearch TempoQueryType = "search"
TempoQueryTypeServiceMap TempoQueryType = "serviceMap"
TempoQueryTypeTraceql TempoQueryType = "traceql"
TempoQueryTypeTraceqlSearch TempoQueryType = "traceqlSearch"
TempoQueryTypeUpload TempoQueryType = "upload"
)
// Defines values for TraceqlFilterType.
const (
TraceqlFilterTypeDynamic TraceqlFilterType = "dynamic"
TraceqlFilterTypeStatic TraceqlFilterType = "static"
)
// Defines values for TraceqlSearchFilterType.
const (
TraceqlSearchFilterTypeDynamic TraceqlSearchFilterType = "dynamic"
TraceqlSearchFilterTypeStatic TraceqlSearchFilterType = "static"
)
// TempoDataQuery defines model for TempoDataQuery.
type TempoDataQuery struct {
type TempoDataQuery = map[string]interface{}
// TempoQuery defines model for TempoQuery.
type TempoQuery struct {
// For mixed data sources the selected datasource is on the query level.
// For non mixed scenarios this is undefined.
// TODO find a better way to do this ^ that's friendly to schema
// TODO this shouldn't be unknown but DataSourceRef | null
Datasource *interface{} `json:"datasource,omitempty"`
Filters []struct {
// Uniquely identify the filter, will not be used in the query generation
Id string `json:"id"`
// The operator that connects the tag to the value, for example: =, >, !=, =~
Operator *string `json:"operator,omitempty"`
// The tag for the search filter, for example: .http.status_code, .service.name, status
Tag *string `json:"tag,omitempty"`
// The type of the filter, can either be static (pre defined in the UI) or dynamic
Type TempoQueryFiltersType `json:"type"`
// The value for the search filter
Value *interface{} `json:"value,omitempty"`
// 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 `json:"valueType,omitempty"`
} `json:"filters"`
// Hide true if query is disabled (ie should not be returned to the dashboard)
Hide *bool `json:"hide,omitempty"`
@ -65,5 +106,35 @@ type TempoDataQuery struct {
SpanName *string `json:"spanName,omitempty"`
}
// The type of the filter, can either be static (pre defined in the UI) or dynamic
type TempoQueryFiltersType string
// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility
type TempoQueryType string
// TraceqlFilter defines model for TraceqlFilter.
type TraceqlFilter struct {
// Uniquely identify the filter, will not be used in the query generation
Id string `json:"id"`
// The operator that connects the tag to the value, for example: =, >, !=, =~
Operator *string `json:"operator,omitempty"`
// The tag for the search filter, for example: .http.status_code, .service.name, status
Tag *string `json:"tag,omitempty"`
// The type of the filter, can either be static (pre defined in the UI) or dynamic
Type TraceqlFilterType `json:"type"`
// The value for the search filter
Value *interface{} `json:"value,omitempty"`
// 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 `json:"valueType,omitempty"`
}
// The type of the filter, can either be static (pre defined in the UI) or dynamic
type TraceqlFilterType string
// TraceqlSearchFilterType static fields are pre-set in the UI, dynamic fields are added by the user
type TraceqlSearchFilterType string

View File

@ -61,7 +61,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
queryRes := backend.DataResponse{}
refID := req.Queries[0].RefID
model := &dataquery.TempoDataQuery{}
model := &dataquery.TempoQuery{}
err := json.Unmarshal(req.Queries[0].JSON, model)
if err != nil {
return result, err

View File

@ -17,6 +17,7 @@ import {
import { LokiQueryField } from '../../loki/components/LokiQueryField';
import { LokiDatasource } from '../../loki/datasource';
import { LokiQuery } from '../../loki/types';
import TraceQLSearch from '../SearchTraceQLEditor/TraceQLSearch';
import { TempoQueryType } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { QueryEditor } from '../traceql/QueryEditor';
@ -28,7 +29,7 @@ import { getDS } from './utils';
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {}
const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql';
const DEFAULT_QUERY_TYPE: TempoQueryType = config.featureToggles.traceqlSearch ? 'traceqlSearch' : 'traceql';
class TempoQueryFieldComponent extends React.PureComponent<Props> {
constructor(props: Props) {
@ -83,7 +84,11 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
{ value: 'serviceMap', label: 'Service Graph' },
];
if (!datasource?.search?.hide) {
if (config.featureToggles.traceqlSearch) {
queryTypeOptions.unshift({ value: 'traceqlSearch', label: 'Search' });
}
if (!config.featureToggles.traceqlSearch && !datasource?.search?.hide) {
queryTypeOptions.unshift({ value: 'nativeSearch', label: 'Search' });
}
@ -141,6 +146,14 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
onRunQuery={this.props.onRunQuery}
/>
)}
{query.queryType === 'traceqlSearch' && (
<TraceQLSearch
datasource={this.props.datasource}
query={query}
onChange={onChange}
onBlur={this.props.onBlur}
/>
)}
{query.queryType === 'upload' && (
<div className={css({ padding: this.props.theme.spacing(2) })}>
<FileDropzone

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Select, HorizontalGroup, Input } from '@grafana/ui';
import { TraceqlFilter } from '../dataquery.gen';
import { operatorSelectableValue } from './utils';
interface Props {
filter: TraceqlFilter;
updateFilter: (f: TraceqlFilter) => void;
isTagsLoading?: boolean;
operators: string[];
}
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
return (
<HorizontalGroup spacing={'none'}>
<Select
inputId={`${filter.id}-operator`}
options={operators.map(operatorSelectableValue)}
value={filter.operator}
onChange={(v) => {
updateFilter({ ...filter, operator: v?.value });
}}
isClearable={false}
aria-label={`select ${filter.id} operator`}
allowCustomValue={true}
width={8}
/>
<Input
value={filter.value}
onChange={(v) => {
updateFilter({ ...filter, value: v.currentTarget.value });
}}
placeholder="e.g. 100ms, 1.2s"
aria-label={`select ${filter.id} value`}
width={18}
/>
</HorizontalGroup>
);
};
export default DurationInput;

View File

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { InlineFieldRow, InlineField } from '@grafana/ui';
interface Props {
label: string;
tooltip?: string;
children: React.ReactElement;
}
const SearchField: FC<Props> = ({ label, tooltip, children }) => {
return (
<InlineFieldRow>
<InlineField label={label} labelWidth={16} grow tooltip={tooltip}>
{children}
</InlineField>
</InlineFieldRow>
);
};
export default SearchField;

View File

@ -0,0 +1,170 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { FetchError } from '@grafana/runtime';
import { TraceqlFilter } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import SearchField from './SearchField';
const getOptionsV2 = jest.fn().mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
value: 'customer',
label: 'customer',
type: 'string',
},
{
value: 'driver',
label: 'driver',
type: 'string',
},
]);
}, 1000);
});
});
jest.mock('../language_provider', () => {
return jest.fn().mockImplementation(() => {
return { getOptionsV2 };
});
});
describe('SearchField', () => {
let user: ReturnType<typeof userEvent.setup>;
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 not render tag if tag is present in field', () => {
const updateFilter = jest.fn((val) => {
return val;
});
const filter: TraceqlFilter = { id: 'test1', type: 'static', valueType: 'string', tag: 'test-tag' };
const { container } = renderSearchField(updateFilter, filter);
expect(container.querySelector(`input[aria-label="select test1 tag"]`)).not.toBeInTheDocument();
expect(container.querySelector(`input[aria-label="select test1 operator"]`)).toBeInTheDocument();
expect(container.querySelector(`input[aria-label="select test1 value"]`)).toBeInTheDocument();
});
it('should update operator when new value is selected in operator input', async () => {
const updateFilter = jest.fn((val) => {
return val;
});
const filter: TraceqlFilter = { id: 'test1', operator: '=', type: 'static', valueType: 'string', tag: 'test-tag' };
const { container } = renderSearchField(updateFilter, filter);
const select = await container.querySelector(`input[aria-label="select test1 operator"]`);
expect(select).not.toBeNull();
expect(select).toBeInTheDocument();
if (select) {
await user.click(select);
jest.advanceTimersByTime(1000);
const largerThanOp = await screen.findByText('>');
await user.click(largerThanOp);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '>' });
}
});
it('should update value when new value is selected in value input', async () => {
const updateFilter = jest.fn((val) => {
return val;
});
const filter: TraceqlFilter = {
id: 'test1',
value: 'old',
type: 'static',
valueType: 'string',
tag: 'test-tag',
};
const { container } = renderSearchField(updateFilter, filter);
const select = await container.querySelector(`input[aria-label="select test1 value"]`);
expect(select).not.toBeNull();
expect(select).toBeInTheDocument();
if (select) {
// Add first value
await user.click(select);
jest.advanceTimersByTime(1000);
const driverVal = await screen.findByText('driver');
await user.click(driverVal);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver'] });
// Add a second value
await user.click(select);
jest.advanceTimersByTime(1000);
const customerVal = await screen.findByText('customer');
await user.click(customerVal);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver', 'customer'] });
// Remove the first value
const firstValRemove = await screen.findByLabelText('Remove driver');
await user.click(firstValRemove);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['customer'] });
}
});
it('should update tag when new value is selected in tag input', async () => {
const updateFilter = jest.fn((val) => {
return val;
});
const filter: TraceqlFilter = {
id: 'test1',
type: 'dynamic',
valueType: 'string',
};
const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']);
const select = await container.querySelector(`input[aria-label="select test1 tag"]`);
expect(select).not.toBeNull();
expect(select).toBeInTheDocument();
if (select) {
// Select tag22 as the tag
await user.click(select);
jest.advanceTimersByTime(1000);
const tag22 = await screen.findByText('tag22');
await user.click(tag22);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag22' });
// Select tag1 as the tag
await user.click(select);
jest.advanceTimersByTime(1000);
const tag1 = await screen.findByText('tag1');
await user.click(tag1);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag1' });
// Remove the tag
const tagRemove = await screen.findByLabelText('select-clear-value');
await user.click(tagRemove);
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: undefined });
}
});
});
const renderSearchField = (updateFilter: (f: TraceqlFilter) => void, filter: TraceqlFilter, tags?: string[]) => {
return render(
<SearchField
datasource={{} as TempoDatasource}
updateFilter={updateFilter}
filter={filter}
setError={function (error: FetchError): void {
throw error;
}}
tags={tags || []}
/>
);
};

View File

@ -0,0 +1,166 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import { FetchError, isFetchError } from '@grafana/runtime';
import { Select, HorizontalGroup } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { TraceqlFilter } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { operators as allOperators } from '../traceql/traceql';
import { operatorSelectableValue } from './utils';
interface Props {
filter: TraceqlFilter;
datasource: TempoDatasource;
updateFilter: (f: TraceqlFilter) => void;
deleteFilter?: (f: TraceqlFilter) => void;
setError: (error: FetchError) => void;
isTagsLoading?: boolean;
tags: string[];
operators?: string[];
}
const SearchField = ({
filter,
datasource,
updateFilter,
deleteFilter,
isTagsLoading,
tags,
setError,
operators,
}: Props) => {
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [isLoadingValues, setIsLoadingValues] = useState(false);
const [options, setOptions] = useState<Array<SelectableValue<string>>>([]);
// 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
const [prevOperator, setPrevOperator] = useState(filter.operator);
const [prevValue, setPrevValue] = useState(filter.value);
useEffect(() => {
if (Array.isArray(filter.value) && filter.value.length > 1 && filter.operator !== '=~') {
setPrevOperator(filter.operator);
updateFilter({ ...filter, operator: '=~' });
}
if (Array.isArray(filter.value) && filter.value.length <= 1 && (prevValue?.length || 0) > 1) {
updateFilter({ ...filter, operator: prevOperator, value: filter.value[0] });
}
}, [prevValue, prevOperator, updateFilter, filter]);
useEffect(() => {
setPrevValue(filter.value);
}, [filter.value]);
const loadOptions = useCallback(
async (name: string) => {
setIsLoadingValues(true);
try {
const options = await languageProvider.getOptionsV2(name);
return options;
} catch (error) {
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
return [];
} finally {
setIsLoadingValues(false);
}
},
[setError, languageProvider]
);
useEffect(() => {
const fetchOptions = async () => {
try {
if (filter.tag) {
setOptions(await loadOptions(filter.tag));
}
} catch (error) {
// Display message if Tempo is connected but search 404's
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchOptions();
}, [languageProvider, loadOptions, setError, filter.tag]);
return (
<HorizontalGroup spacing={'none'}>
{filter.type === 'dynamic' && (
<Select
inputId={`${filter.id}-tag`}
isLoading={isTagsLoading}
options={tags.map((t) => ({ label: t, value: t }))}
onOpenMenu={() => tags}
value={filter.tag}
onChange={(v) => {
updateFilter({ ...filter, tag: v?.value });
}}
placeholder="Select tag"
isClearable
aria-label={`select ${filter.id} tag`}
allowCustomValue={true}
/>
)}
<Select
inputId={`${filter.id}-operator`}
options={(operators || allOperators).map(operatorSelectableValue)}
value={filter.operator}
onChange={(v) => {
updateFilter({ ...filter, operator: v?.value });
}}
isClearable={false}
aria-label={`select ${filter.id} operator`}
allowCustomValue={true}
width={8}
/>
<Select
inputId={`${filter.id}-value`}
isLoading={isLoadingValues}
options={options}
onOpenMenu={() => {
if (filter.tag) {
loadOptions(filter.tag);
}
}}
value={filter.value}
onChange={(val) => {
if (Array.isArray(val)) {
updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type });
} else {
updateFilter({ ...filter, value: val?.value, valueType: val?.type });
}
}}
placeholder="Select value"
isClearable={false}
aria-label={`select ${filter.id} value`}
allowCustomValue={true}
isMulti
/>
{filter.type === 'dynamic' && (
<AccessoryButton
variant={'secondary'}
icon={'times'}
onClick={() => deleteFilter?.(filter)}
tooltip={'Remove tag'}
aria-label={`remove tag with ID ${filter.id}`}
/>
)}
</HorizontalGroup>
);
};
export default SearchField;

View File

@ -0,0 +1,58 @@
import React, { useEffect, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { AccessoryButton } from '@grafana/experimental';
import { FetchError } from '@grafana/runtime';
import { HorizontalGroup, VerticalGroup } from '@grafana/ui';
import { TraceqlFilter } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import SearchField from './SearchField';
interface Props {
updateFilter: (f: TraceqlFilter) => void;
deleteFilter: (f: TraceqlFilter) => void;
filters: TraceqlFilter[];
datasource: TempoDatasource;
setError: (error: FetchError) => void;
tags: string[];
isTagsLoading: boolean;
}
const TagsInput = ({ updateFilter, deleteFilter, filters, datasource, setError, tags, isTagsLoading }: Props) => {
const generateId = () => uuidv4().slice(0, 8);
const handleOnAdd = useCallback(
() => updateFilter({ id: generateId(), type: 'dynamic', operator: '=' }),
[updateFilter]
);
useEffect(() => {
if (!filters?.find((f) => f.type === 'dynamic')) {
handleOnAdd();
}
}, [filters, handleOnAdd]);
return (
<HorizontalGroup spacing={'md'} align={'flex-start'}>
<VerticalGroup spacing={'xs'}>
{filters
?.filter((f) => f.type === 'dynamic')
.map((f) => (
<SearchField
filter={f}
key={f.id}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={tags}
isTagsLoading={isTagsLoading}
deleteFilter={deleteFilter}
/>
))}
</VerticalGroup>
<AccessoryButton variant={'secondary'} icon={'plus'} onClick={handleOnAdd} title={'Add tag'} />
</HorizontalGroup>
);
};
export default TagsInput;

View File

@ -0,0 +1,131 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TempoDatasource } from '../datasource';
import { TempoQuery } from '../types';
import TraceQLSearch from './TraceQLSearch';
const getOptionsV2 = jest.fn().mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
value: 'customer',
label: 'customer',
type: 'string',
},
{
value: 'driver',
label: 'driver',
type: 'string',
},
]);
}, 1000);
});
});
const getTags = jest.fn().mockImplementation(() => {
return ['foo', 'bar'];
});
jest.mock('../language_provider', () => {
return jest.fn().mockImplementation(() => {
return { getOptionsV2, getTags };
});
});
describe('TraceQLSearch', () => {
let user: ReturnType<typeof userEvent.setup>;
let query: TempoQuery = {
refId: 'A',
queryType: 'traceqlSearch',
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
query: '',
filters: [{ id: 'min-duration', operator: '>', type: 'static', valueType: 'duration', tag: 'duration' }],
};
const onChange = (q: TempoQuery) => {
query = q;
};
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 update operator when new value is selected in operator input', async () => {
const { container } = render(
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
);
const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`);
expect(minDurationOperator).not.toBeNull();
expect(minDurationOperator).toBeInTheDocument();
expect(await screen.findByText('>')).toBeInTheDocument();
if (minDurationOperator) {
await user.click(minDurationOperator);
jest.advanceTimersByTime(1000);
const regexOp = await screen.findByText('>=');
await user.click(regexOp);
const minDurationFilter = query.filters.find((f) => f.id === 'min-duration');
expect(minDurationFilter).not.toBeNull();
expect(minDurationFilter?.operator).toBe('>=');
}
});
it('should add new filter when new value is selected in the service name section', async () => {
const { container } = render(
<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />
);
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
expect(serviceNameValue).not.toBeNull();
expect(serviceNameValue).toBeInTheDocument();
expect(query.filters.find((f) => f.id === 'service-name')).not.toBeDefined();
if (serviceNameValue) {
await user.click(serviceNameValue);
jest.advanceTimersByTime(1000);
const customerValue = await screen.findByText('customer');
await user.click(customerValue);
const nameFilter = query.filters.find((f) => f.id === 'service-name');
expect(nameFilter).not.toBeNull();
expect(nameFilter?.operator).toBe('=');
expect(nameFilter?.value).toStrictEqual(['customer']);
expect(nameFilter?.tag).toBe('.service.name');
}
});
it('should add new filter when new filter button is clicked and remove filter when remove button is clicked', async () => {
render(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
const dynamicFilters = query.filters.filter((f) => f.type === 'dynamic');
expect(dynamicFilters.length).toBe(1);
const addButton = await screen.findByTitle('Add tag');
await user.click(addButton);
jest.advanceTimersByTime(1000);
// We have to rerender here so it picks up the new dynamic field
render(<TraceQLSearch datasource={{} as TempoDatasource} query={query} onChange={onChange} />);
const newDynamicFilters = query.filters.filter((f) => f.type === 'dynamic');
expect(newDynamicFilters.length).toBe(2);
const notInitialDynamic = newDynamicFilters.find((f) => f.id !== dynamicFilters[0].id);
const secondDynamicRemoveButton = await screen.findByLabelText(`remove tag with ID ${notInitialDynamic?.id}`);
await waitFor(() => expect(secondDynamicRemoveButton).toBeInTheDocument());
if (secondDynamicRemoveButton) {
await user.click(secondDynamicRemoveButton);
expect(query.filters.filter((f) => f.type === 'dynamic')).toStrictEqual(dynamicFilters);
}
});
});

View File

@ -0,0 +1,190 @@
import { css } from '@emotion/css';
import React, { useState, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { EditorRow } from '@grafana/experimental';
import { FetchError } from '@grafana/runtime';
import { Alert, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
import { TraceqlFilter } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
import { CompletionProvider } from '../traceql/autocomplete';
import { traceqlGrammar } from '../traceql/traceql';
import { TempoQuery } from '../types';
import DurationInput from './DurationInput';
import InlineSearchField from './InlineSearchField';
import SearchField from './SearchField';
import TagsInput from './TagsInput';
import { generateQueryFromFilters, replaceAt } from './utils';
interface Props {
datasource: TempoDatasource;
query: TempoQuery;
onChange: (value: TempoQuery) => void;
onBlur?: () => void;
}
const TraceQLSearch = ({ datasource, query, onChange }: Props) => {
const styles = useStyles2(getStyles);
const [error, setError] = useState<Error | FetchError | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [isTagsLoading, setIsTagsLoading] = useState(true);
const [traceQlQuery, setTraceQlQuery] = useState<string>('');
const updateFilter = (s: TraceqlFilter) => {
const copy = { ...query };
copy.filters ||= [];
const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id);
if (indexOfFilter >= 0) {
// update in place if the filter already exists, for consistency and to avoid UI bugs
copy.filters = replaceAt(copy.filters, indexOfFilter, s);
} else {
copy.filters.push(s);
}
onChange(copy);
};
const deleteFilter = (s: TraceqlFilter) => {
onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) });
};
useEffect(() => {
setTraceQlQuery(generateQueryFromFilters(query.filters || []));
}, [query]);
const findFilter = (id: string) => query.filters?.find((f) => f.id === id);
useEffect(() => {
const fetchTags = async () => {
try {
await datasource.languageProvider.start();
const tags = datasource.languageProvider.getTags();
if (tags) {
// This is needed because the /api/v2/search/tag/${tag}/values API expects "status" and the v1 API expects "status.code"
// so Tempo doesn't send anything and we inject it here for the autocomplete
if (!tags.find((t) => t === 'status')) {
tags.push('status');
}
const tagsWithDot = tags.sort().map((t) => `.${t}`);
setTags(tagsWithDot);
setIsTagsLoading(false);
}
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchTags();
}, [datasource]);
return (
<>
<div className={styles.container}>
<div>
<InlineSearchField label={'Service Name'}>
<SearchField
filter={
findFilter('service-name') || {
id: 'service-name',
type: 'static',
tag: '.service.name',
operator: '=',
}
}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={[]}
operators={['=', '!=', '=~']}
/>
</InlineSearchField>
<InlineSearchField label={'Span Name'}>
<SearchField
filter={findFilter('span-name') || { id: 'span-name', type: 'static', tag: 'name', operator: '=' }}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
tags={[]}
operators={['=', '!=', '=~']}
/>
</InlineSearchField>
<InlineSearchField label={'Duration'} tooltip="The span duration, i.e. end - start time of the span">
<HorizontalGroup spacing={'sm'}>
<DurationInput
filter={
findFilter('min-duration') || {
id: 'min-duration',
type: 'static',
tag: 'duration',
operator: '>',
valueType: 'duration',
}
}
operators={['>', '>=']}
updateFilter={updateFilter}
/>
<DurationInput
filter={
findFilter('max-duration') || {
id: 'max-duration',
type: 'static',
tag: 'duration',
operator: '<',
valueType: 'duration',
}
}
operators={['<', '<=']}
updateFilter={updateFilter}
/>
</HorizontalGroup>
</InlineSearchField>
<InlineSearchField label={'Tags'}>
<TagsInput
filters={query.filters}
datasource={datasource}
setError={setError}
updateFilter={updateFilter}
deleteFilter={deleteFilter}
tags={[...CompletionProvider.intrinsics, ...tags]}
isTagsLoading={isTagsLoading}
/>
</InlineSearchField>
</div>
<EditorRow>
<RawQuery query={traceQlQuery} lang={{ grammar: traceqlGrammar, name: 'traceql' }} />
</EditorRow>
<TempoQueryBuilderOptions onChange={onChange} query={query} />
</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 TraceQLSearch;
const getStyles = (theme: GrafanaTheme2) => ({
alert: css`
max-width: 75ch;
margin-top: ${theme.spacing(2)};
`,
container: css`
display: flex;
gap: 4px;
flex-wrap: wrap;
flex-direction: column;
`,
});

View File

@ -0,0 +1,57 @@
import { generateQueryFromFilters } 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', type: 'static', tag: 'footag', operator: '=' }])).toBe('{}');
});
it('a field with value but without tag', () => {
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', value: 'foovalue', operator: '=' }])).toBe('{}');
});
it('a field with value and tag but without operator', () => {
expect(generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue' }])).toBe('{}');
});
it('a field with tag, operator and tag', () => {
expect(
generateQueryFromFilters([{ id: 'foo', type: 'static', tag: 'footag', value: 'foovalue', operator: '=' }])
).toBe('{footag="foovalue"}');
});
it('a field with valueType as integer', () => {
expect(
generateQueryFromFilters([
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' },
])
).toBe('{footag>1234}');
});
it('two fields with everything filled in', () => {
expect(
generateQueryFromFilters([
{ id: 'foo', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', type: 'dynamic', 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', type: 'static', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', type: 'dynamic', 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', type: 'static', value: '1234', operator: '>=', valueType: 'integer' },
{ id: 'bar', type: 'dynamic', tag: 'bartag', operator: '=', valueType: 'string' },
])
).toBe('{}');
});
});

View File

@ -0,0 +1,57 @@
import { SelectableValue } from '@grafana/data';
import { TraceqlFilter } from '../dataquery.gen';
export const generateQueryFromFilters = (filters: TraceqlFilter[]) => {
return `{${filters
.filter((f) => f.tag && f.operator && f.value?.length)
.map((f) => `${f.tag}${f.operator}${valueHelper(f)}`)
.join(' && ')}}`;
};
const valueHelper = (f: TraceqlFilter) => {
if (Array.isArray(f.value) && f.value.length > 1) {
return `"${f.value.join('|')}"`;
}
if (!f.valueType || f.valueType === 'string') {
return `"${f.value}"`;
}
return f.value;
};
export function replaceAt<T>(array: T[], index: number, value: T) {
const ret = array.slice(0);
ret[index] = value;
return ret;
}
export const operatorSelectableValue = (op: string) => {
const result: SelectableValue = { label: op, value: op };
switch (op) {
case '=':
result.description = 'Equals';
break;
case '!=':
result.description = 'Not equals';
break;
case '>':
result.description = 'Greater';
break;
case '>=':
result.description = 'Greater or Equal';
break;
case '<':
result.description = 'Less';
break;
case '<=':
result.description = 'Less or Equal';
break;
case '=~':
result.description = 'Matches regex';
break;
case '!~':
result.description = 'Does not match regex';
break;
}
return result;
};

View File

@ -30,27 +30,45 @@ composableKinds: DataQuery: {
{
schemas: [
{
common.DataQuery
// TraceQL query or trace ID
query: string
// Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
search?: string
// Query traces by service name
serviceName?: string
// Query traces by span name
spanName?: string
// Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms
minDuration?: string
// Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms
maxDuration?: string
// Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
serviceMapQuery?: string
// Defines the maximum number of traces that are returned from Tempo
limit?: int64
#TempoQuery: common.DataQuery & {
// TraceQL query or trace ID
query: string
// Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
search?: string
// Query traces by service name
serviceName?: string
// Query traces by span name
spanName?: string
// Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms
minDuration?: string
// Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms
maxDuration?: string
// Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
serviceMapQuery?: string
// Defines the maximum number of traces that are returned from Tempo
limit?: int64
filters: [...#TraceqlFilter]
} @cuetsy(kind="interface") @grafana(TSVeneer="type")
// search = Loki search, nativeSearch = Tempo search for backwards compatibility
#TempoQueryType: "traceql" | "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
#TraceqlSearchFilterType: "static" | "dynamic" @cuetsy(kind="type")
#TraceqlFilter: {
// Uniquely identify the filter, will not be used in the query generation
id: string
// The type of the filter, can either be static (pre defined in the UI) or dynamic
type: #TraceqlSearchFilterType
// The tag for the search filter, for example: .http.status_code, .service.name, status
tag?: string
// The operator that connects the tag to the value, for example: =, >, !=, =~
operator?: string
// The value for the search filter
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
valueType?: string
} @cuetsy(kind="interface")
},
]
},

View File

@ -12,12 +12,8 @@ import * as common from '@grafana/schema';
export const DataQueryModelVersion = Object.freeze([0, 0]);
/**
* search = Loki search, nativeSearch = Tempo search for backwards compatibility
*/
export type TempoQueryType = ('traceql' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear');
export interface Tempo extends common.DataQuery {
export interface TempoQuery extends common.DataQuery {
filters: Array<TraceqlFilter>;
/**
* Defines the maximum number of traces that are returned from Tempo
*/
@ -51,3 +47,46 @@ export interface Tempo extends common.DataQuery {
*/
spanName?: string;
}
export const defaultTempoQuery: Partial<TempoQuery> = {
filters: [],
};
/**
* search = Loki search, nativeSearch = Tempo search for backwards compatibility
*/
export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'search' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear');
/**
* static fields are pre-set in the UI, dynamic fields are added by the user
*/
export type TraceqlSearchFilterType = ('static' | 'dynamic');
export interface TraceqlFilter {
/**
* Uniquely identify the filter, will not be used in the query generation
*/
id: string;
/**
* The operator that connects the tag to the value, for example: =, >, !=, =~
*/
operator?: string;
/**
* The tag for the search filter, for example: .http.status_code, .service.name, status
*/
tag?: string;
/**
* The type of the filter, can either be static (pre defined in the UI) or dynamic
*/
type: TraceqlSearchFilterType;
/**
* The value for the search filter
*/
value?: (string | Array<string>);
/**
* 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;
}
export interface Tempo {}

View File

@ -76,6 +76,7 @@ describe('Tempo data source', () => {
search: '$interpolationVar',
minDuration: '$interpolationVar',
maxDuration: '$interpolationVar',
filters: [],
};
}
@ -243,6 +244,7 @@ describe('Tempo data source', () => {
minDuration: '$interpolationVar',
maxDuration: '$interpolationVar',
limit: 10,
filters: [],
};
const builtQuery = ds.buildSearchQuery(tempoQuery);
expect(builtQuery).toStrictEqual({
@ -260,6 +262,7 @@ describe('Tempo data source', () => {
refId: 'A',
query: '',
search: '',
filters: [],
};
const builtQuery = ds.buildSearchQuery(tempoQuery);
expect(builtQuery).toStrictEqual({
@ -275,6 +278,7 @@ describe('Tempo data source', () => {
refId: 'A',
query: '',
search: '',
filters: [],
};
const timeRange = { startTime: 0, endTime: 1000 };
const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange);
@ -289,6 +293,7 @@ describe('Tempo data source', () => {
it('formats native search query history correctly', () => {
const ds = new TempoDatasource(defaultSettings);
const tempoQuery: TempoQuery = {
filters: [],
queryType: 'nativeSearch',
refId: 'A',
query: '',

View File

@ -34,6 +34,7 @@ import { LokiOptions } from '../loki/types';
import { PrometheusDatasource } from '../prometheus/datasource';
import { PromQuery } from '../prometheus/types';
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
import {
failedMetric,
histogramMetric,
@ -223,6 +224,36 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
}
}
if (targets.traceqlSearch?.length) {
try {
const queryValue = generateQueryFromFilters(targets.traceqlSearch[0].filters);
reportInteraction('grafana_traces_traceql_search_queried', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
query: queryValue ?? '',
});
subQueries.push(
this._request('/api/search', {
q: queryValue,
limit: options.targets[0].limit,
start: options.range.from.unix(),
end: options.range.to.unix(),
}).pipe(
map((response) => {
return {
data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings),
};
}),
catchError((error) => {
return of({ error: { message: error.data.message }, data: [] });
})
)
);
} catch (error) {
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
}
}
if (targets.upload?.length) {
if (this.uploadedJson) {

View File

@ -20,6 +20,7 @@ interface Props {
onChange: (val: string) => void;
onRunQuery: () => void;
datasource: TempoDatasource;
readOnly?: boolean;
}
export function TraceQLEditor(props: Props) {
@ -35,6 +36,7 @@ export function TraceQLEditor(props: Props) {
onBlur={onChange}
onChange={onChange}
containerStyles={styles.queryField}
readOnly={props.readOnly}
monacoOptions={{
folding: false,
fontSize: 14,
@ -52,9 +54,11 @@ export function TraceQLEditor(props: Props) {
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor));
setupActions(editor, monaco, onRunQuery);
setupPlaceholder(editor, monaco, styles);
if (!props.readOnly) {
setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor));
setupActions(editor, monaco, onRunQuery);
setupPlaceholder(editor, monaco, styles);
}
setupAutoSize(editor);
}}
/>

View File

@ -1,3 +1,5 @@
import { Grammar } from 'prismjs';
export const languageConfiguration = {
// the default separators except `@$`
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
@ -20,7 +22,7 @@ export const languageConfiguration = {
folding: {},
};
const operators = ['=', '!=', '>', '<', '>=', '<=', '=~', '!~'];
export const operators = ['=', '!=', '>', '<', '>=', '<=', '=~'];
const intrinsics = ['duration', 'name', 'status', 'parent'];
@ -134,3 +136,34 @@ export const languageDefinition = {
languageConfiguration,
},
};
export const traceqlGrammar: Grammar = {
comment: {
pattern: /#.*/,
},
'span-set': {
pattern: /\{[^}]*}/,
inside: {
filter: {
pattern: /([\w.\/-]+)?(\s*)(([!=+\-<>~]+)\s*("([^"\n&]+)?"?|([^"\n\s&|}]+))?)/g,
inside: {
comment: {
pattern: /#.*/,
},
'label-key': {
pattern: /[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/,
alias: 'attr-name',
},
'label-value': {
pattern: /("(?:\\.|[^\\"])*")|(\w+)/,
alias: 'attr-value',
},
},
},
punctuation: /[}{&|]/,
},
},
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|`, 'i'),
punctuation: /[{};()`,.]/,
};

View File

@ -4,7 +4,7 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsS
import { LokiQuery } from '../loki/types';
import { Tempo as TempoBase, TempoQueryType } from './dataquery.gen';
import { TempoQuery as TempoBase, TempoQueryType } from './dataquery.gen';
export interface SearchQueryParams {
minDuration?: string;