mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
5db0d14606
commit
fd37ff29b5
@ -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 | 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 | 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 |
|
||||
|----------|------|----------|-------------|
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -80,4 +80,5 @@ export interface FeatureToggles {
|
||||
lokiQuerySplitting?: boolean;
|
||||
individualCookiePreferences?: boolean;
|
||||
drawerDataSourcePicker?: boolean;
|
||||
traceqlSearch?: boolean;
|
||||
}
|
||||
|
@ -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] : [];
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -262,4 +262,8 @@ const (
|
||||
// FlagDrawerDataSourcePicker
|
||||
// Changes the user experience for data source selection to a drawer.
|
||||
FlagDrawerDataSourcePicker = "drawerDataSourcePicker"
|
||||
|
||||
// FlagTraceqlSearch
|
||||
// Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries
|
||||
FlagTraceqlSearch = "traceqlSearch"
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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;
|
@ -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;
|
@ -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 || []}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
});
|
@ -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('{}');
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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")
|
||||
},
|
||||
]
|
||||
},
|
||||
|
@ -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 {}
|
||||
|
@ -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: '',
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
@ -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: /[{};()`,.]/,
|
||||
};
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user