diff --git a/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md b/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md
index 252599686a4..a16582d8e3d 100644
--- a/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md
+++ b/docs/sources/developers/kinds/composable/tempodataquery/schema-reference.md
@@ -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))* A - Z |
-| `datasource` | | No | *(Inherited from [DataQuery](#dataquery))* 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 |
-| `hide` | boolean | No | *(Inherited from [DataQuery](#dataquery))* true if query is disabled (ie should not be returned to the dashboard) |
-| `key` | string | No | *(Inherited from [DataQuery](#dataquery))* 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))* Specify the query flavor 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. 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 |
-| `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 TODO make this required and give it a default |
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index 9353b6202aa..776b5ca774e 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -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
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index 3df66b76d6b..f5688619173 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -80,4 +80,5 @@ export interface FeatureToggles {
lokiQuerySplitting?: boolean;
individualCookiePreferences?: boolean;
drawerDataSourcePicker?: boolean;
+ traceqlSearch?: boolean;
}
diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx
index 0a19c5f7c01..3241839dbf9 100644
--- a/packages/grafana-ui/src/components/Select/SelectBase.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx
@@ -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({
// 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] : [];
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index b8f15a158ad..57fb12c13f1 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -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,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 63e177e54a9..c26697b1a81 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -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"
)
diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
index 83c2d3ff851..06fb1fb74e2 100644
--- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
+++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
@@ -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
diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go
index f52ec3b2c44..752c9d34a1f 100644
--- a/pkg/tsdb/tempo/tempo.go
+++ b/pkg/tsdb/tempo/tempo.go
@@ -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
diff --git a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx
index e8dc49370ce..2bcc6650fa1 100644
--- a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx
+++ b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx
@@ -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, Themeable2 {}
-const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql';
+const DEFAULT_QUERY_TYPE: TempoQueryType = config.featureToggles.traceqlSearch ? 'traceqlSearch' : 'traceql';
class TempoQueryFieldComponent extends React.PureComponent {
constructor(props: Props) {
@@ -83,7 +84,11 @@ class TempoQueryFieldComponent extends React.PureComponent {
{ 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 {
onRunQuery={this.props.onRunQuery}
/>
)}
+ {query.queryType === 'traceqlSearch' && (
+
+ )}
{query.queryType === 'upload' && (
void;
+ isTagsLoading?: boolean;
+ operators: string[];
+}
+const DurationInput = ({ filter, operators, updateFilter }: Props) => {
+ return (
+
+
+ );
+};
+
+export default DurationInput;
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx
new file mode 100644
index 00000000000..46d4855e635
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/InlineSearchField.tsx
@@ -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 = ({ label, tooltip, children }) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export default SearchField;
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx
new file mode 100644
index 00000000000..a8d472b5c44
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx
@@ -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;
+
+ 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(
+
+ );
+};
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx
new file mode 100644
index 00000000000..cbdc6fa5eda
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx
@@ -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>>([]);
+ // 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 (
+
+ {filter.type === 'dynamic' && (
+
+ );
+};
+
+export default SearchField;
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx
new file mode 100644
index 00000000000..c8a3d10edd7
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx
@@ -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 (
+
+
+ {filters
+ ?.filter((f) => f.type === 'dynamic')
+ .map((f) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default TagsInput;
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx
new file mode 100644
index 00000000000..d97517f788b
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx
@@ -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;
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+ 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();
+
+ 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();
+
+ 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);
+ }
+ });
+});
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx
new file mode 100644
index 00000000000..0e2047caae2
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx
@@ -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(null);
+
+ const [tags, setTags] = useState([]);
+ const [isTagsLoading, setIsTagsLoading] = useState(true);
+ const [traceQlQuery, setTraceQlQuery] = useState('');
+
+ 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 (
+ <>
+