Tempo: Deprecate old search (#84498)

* Add logic to update query from old search to new search i.e. nativeSearch to traceqlSearch

* Remove nativeSearch query and transform

* Update tracking

* Remove nativeSearch from query field

* Udpdate gen comment

* Fix tests

* Add tests

* Remove comments

* Remove log

* Remove log

* Update comment

* Update ids etc for migratedQuery

* Remove old nativeSearch folder

* Fix tests, manual testing
This commit is contained in:
Joey 2024-03-18 09:17:10 +00:00 committed by GitHub
parent 1de4187a6e
commit f5e83d07a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 153 additions and 1362 deletions

View File

@ -69,9 +69,6 @@ export const defaultTempoQuery: Partial<TempoQuery> = {
groupBy: [],
};
/**
* nativeSearch = Tempo search for backwards compatibility
*/
export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear');
/**

View File

@ -141,7 +141,7 @@ type TempoQuery struct {
TableType *SearchTableType `json:"tableType,omitempty"`
}
// TempoQueryType nativeSearch = Tempo search for backwards compatibility
// TempoQueryType defines model for TempoQueryType.
type TempoQueryType string
// TraceqlFilter defines model for TraceqlFilter.

View File

@ -1,172 +0,0 @@
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 NativeSearch from './NativeSearch';
const getOptionsV1 = jest.fn().mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
value: 'customer',
label: 'customer',
},
{
value: 'driver',
label: 'driver',
},
]);
}, 1000);
});
});
// Have to mock CodeEditor else it causes act warnings
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
CodeEditor: function CodeEditor({ value, onSave }: { value: string; onSave: (newQuery: string) => void }) {
return <input data-testid="mockeditor" value={value} onChange={(event) => onSave(event.target.value)} />;
},
}));
jest.mock('../language_provider', () => {
return jest.fn().mockImplementation(() => {
return { getOptionsV1 };
});
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => ({
replace: jest.fn(),
containsTemplate: (val: string): boolean => {
return val.includes('$');
},
}),
}));
let mockQuery = {
refId: 'A',
queryType: 'nativeSearch',
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
serviceName: 'driver',
spanName: 'customer',
} as TempoQuery;
describe('NativeSearch', () => {
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 show loader when there is a delay', async () => {
render(
<NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={jest.fn()} onRunQuery={jest.fn()} />
);
const select = screen.getByRole('combobox', { name: 'select-service-name' });
await user.click(select);
const loader = screen.getByText('Loading options...');
expect(loader).toBeInTheDocument();
jest.advanceTimersByTime(1000);
await waitFor(() => expect(screen.queryByText('Loading options...')).not.toBeInTheDocument());
});
it('should call the `onChange` function on click of the Input', async () => {
const promise = Promise.resolve();
const handleOnChange = jest.fn(() => promise);
const fakeOptionChoice = {
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
queryType: 'nativeSearch',
refId: 'A',
serviceName: 'driver',
spanName: 'customer',
};
render(
<NativeSearch
datasource={{} as TempoDatasource}
query={mockQuery}
onChange={handleOnChange}
onRunQuery={() => {}}
/>
);
const select = await screen.findByRole('combobox', { name: 'select-service-name' });
expect(select).toBeInTheDocument();
await user.click(select);
jest.advanceTimersByTime(1000);
await user.type(select, 'd');
const driverOption = await screen.findByText('driver');
await user.click(driverOption);
expect(handleOnChange).toHaveBeenCalledWith(fakeOptionChoice);
});
it('should filter the span dropdown when user types a search value', async () => {
render(
<NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={() => {}} onRunQuery={() => {}} />
);
const select = await screen.findByRole('combobox', { name: 'select-service-name' });
await user.click(select);
jest.advanceTimersByTime(1000);
expect(select).toBeInTheDocument();
await user.type(select, 'd');
let option = await screen.findByText('driver');
expect(option).toBeDefined();
await user.type(select, 'a');
option = await screen.findByText('Hit enter to add');
expect(option).toBeDefined();
});
it('should add variable to select menu options', async () => {
mockQuery = {
...mockQuery,
refId: '121314',
serviceName: '$service',
spanName: '$span',
};
render(
<NativeSearch datasource={{} as TempoDatasource} query={mockQuery} onChange={() => {}} onRunQuery={() => {}} />
);
const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' });
expect(asyncServiceSelect).toBeInTheDocument();
await user.click(asyncServiceSelect);
jest.advanceTimersByTime(3000);
await user.type(asyncServiceSelect, '$');
const serviceOption = await screen.findByText('$service');
expect(serviceOption).toBeDefined();
const asyncSpanSelect = screen.getByRole('combobox', { name: 'select-span-name' });
expect(asyncSpanSelect).toBeInTheDocument();
await user.click(asyncSpanSelect);
jest.advanceTimersByTime(3000);
await user.type(asyncSpanSelect, '$');
const operationOption = await screen.findByText('$span');
expect(operationOption).toBeDefined();
});
});

View File

@ -1,276 +0,0 @@
import { css } from '@emotion/css';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime';
import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui';
import { DEFAULT_LIMIT, TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { TempoQuery } from '../types';
import { TagsField } from './TagsField/TagsField';
interface Props {
datasource: TempoDatasource;
query: TempoQuery;
onChange: (value: TempoQuery) => void;
onBlur?: () => void;
onRunQuery: () => void;
}
const durationPlaceholder = 'e.g. 1.2s, 100ms';
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => {
const styles = useStyles2(getStyles);
const [alertText, setAlertText] = useState<string>();
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>();
const [spanOptions, setSpanOptions] = useState<Array<SelectableValue<string>>>();
const [error, setError] = useState<Error | FetchError | null>(null);
const [inputErrors, setInputErrors] = useState<{ [key: string]: boolean }>({});
const [isLoading, setIsLoading] = useState<{
serviceName: boolean;
spanName: boolean;
}>({
serviceName: false,
spanName: false,
});
const loadOptions = useCallback(
async (name: string, query = '') => {
const lpName = name === 'serviceName' ? 'service.name' : 'name';
setIsLoading((prevValue) => ({ ...prevValue, [name]: true }));
try {
const options = await languageProvider.getOptionsV1(lpName);
const filteredOptions = options.filter((item) => (item.value ? fuzzyMatch(item.value, query).found : false));
setAlertText(undefined);
setError(null);
return filteredOptions;
} catch (error) {
if (isFetchError(error) && error?.status === 404) {
setError(error);
} else if (error instanceof Error) {
setAlertText(`Error: ${error.message}`);
}
return [];
} finally {
setIsLoading((prevValue) => ({ ...prevValue, [name]: false }));
}
},
[languageProvider, setAlertText]
);
useEffect(() => {
const fetchOptions = async () => {
try {
const [services, spans] = await Promise.all([loadOptions('serviceName'), loadOptions('spanName')]);
if (query.serviceName && getTemplateSrv().containsTemplate(query.serviceName)) {
services.push(toOption(query.serviceName));
}
setServiceOptions(services);
if (query.spanName && getTemplateSrv().containsTemplate(query.spanName)) {
spans.push(toOption(query.spanName));
}
setSpanOptions(spans);
setAlertText(undefined);
setError(null);
} 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) {
setAlertText(`Error: ${error.message}`);
}
}
};
fetchOptions();
}, [languageProvider, loadOptions, query.serviceName, query.spanName, setAlertText]);
const onKeyDown = (keyEvent: React.KeyboardEvent) => {
if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) {
onRunQuery();
}
};
const handleOnChange = useCallback(
(value: string) => {
onChange({
...query,
search: value,
});
},
[onChange, query]
);
const templateSrv: TemplateSrv = getTemplateSrv();
return (
<>
<div className={styles.container}>
<Alert title="Deprecated query type" severity="warning">
This query type has been deprecated and will be removed in Grafana v10.3. Please migrate to another Tempo
query type.
</Alert>
<InlineFieldRow>
<InlineField label="Service Name" labelWidth={14} grow>
<Select
inputId="service"
options={serviceOptions}
onOpenMenu={() => {
loadOptions('serviceName');
}}
isLoading={isLoading.serviceName}
value={serviceOptions?.find((v) => v?.value === query.serviceName) || query.serviceName}
onChange={(v) => {
onChange({
...query,
serviceName: v?.value,
});
}}
placeholder="Select a service"
isClearable
onKeyDown={onKeyDown}
aria-label={'select-service-name'}
allowCustomValue={true}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Span Name" labelWidth={14} grow>
<Select
inputId="spanName"
options={spanOptions}
onOpenMenu={() => {
loadOptions('spanName');
}}
isLoading={isLoading.spanName}
value={spanOptions?.find((v) => v?.value === query.spanName) || query.spanName}
onChange={(v) => {
onChange({
...query,
spanName: v?.value,
});
}}
placeholder="Select a span"
isClearable
onKeyDown={onKeyDown}
aria-label={'select-span-name'}
allowCustomValue={true}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in logfmt.">
<TagsField
placeholder="http.status_code=200 error=true"
value={query.search || ''}
onChange={handleOnChange}
onBlur={onBlur}
datasource={datasource}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Min Duration" invalid={!!inputErrors.minDuration} labelWidth={14} grow>
<Input
id="minDuration"
value={query.minDuration || ''}
placeholder={durationPlaceholder}
onBlur={() => {
const templatedMinDuration = templateSrv.replace(query.minDuration ?? '');
if (query.minDuration && !isValidGoDuration(templatedMinDuration)) {
setInputErrors({ ...inputErrors, minDuration: true });
} else {
setInputErrors({ ...inputErrors, minDuration: false });
}
}}
onChange={(v) =>
onChange({
...query,
minDuration: v.currentTarget.value,
})
}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Max Duration" invalid={!!inputErrors.maxDuration} labelWidth={14} grow>
<Input
id="maxDuration"
value={query.maxDuration || ''}
placeholder={durationPlaceholder}
onBlur={() => {
const templatedMaxDuration = templateSrv.replace(query.maxDuration ?? '');
if (query.maxDuration && !isValidGoDuration(templatedMaxDuration)) {
setInputErrors({ ...inputErrors, maxDuration: true });
} else {
setInputErrors({ ...inputErrors, maxDuration: false });
}
}}
onChange={(v) =>
onChange({
...query,
maxDuration: v.currentTarget.value,
})
}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
label="Limit"
invalid={!!inputErrors.limit}
labelWidth={14}
grow
tooltip="Maximum number of returned results"
>
<Input
id="limit"
value={query.limit || ''}
placeholder={`Default: ${DEFAULT_LIMIT}`}
type="number"
onChange={(v) => {
let limit = v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined;
if (limit && (!Number.isInteger(limit) || limit <= 0)) {
setInputErrors({ ...inputErrors, limit: true });
} else {
setInputErrors({ ...inputErrors, limit: false });
}
onChange({
...query,
limit: v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined,
});
}}
onKeyDown={onKeyDown}
/>
</InlineField>
</InlineFieldRow>
</div>
{error ? (
<Alert title="Unable to connect to Tempo search" severity="info" className={styles.alert}>
Please ensure that Tempo is configured with search enabled. If you would like to hide this tab, you can
configure it in the <a href={`/datasources/edit/${datasource.uid}`}>datasource settings</a>.
</Alert>
) : null}
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
};
export default NativeSearch;
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
maxWidth: '500px',
}),
alert: css({
maxWidth: '75ch',
marginTop: theme.spacing(2),
}),
});

View File

@ -1,186 +0,0 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { TempoDatasource } from '../../datasource';
import { CompletionProvider } from './autocomplete';
import { languageDefinition } from './syntax';
interface Props {
placeholder: string;
value: string;
onChange: (val: string) => void;
onBlur?: () => void;
datasource: TempoDatasource;
}
export function TagsField(props: Props) {
const [alertText, setAlertText] = useState<string>();
const { onChange, onBlur, placeholder } = props;
const setupAutocompleteFn = useAutocomplete(props.datasource, setAlertText);
const theme = useTheme2();
const styles = getStyles(theme, placeholder);
return (
<>
<CodeEditor
value={props.value}
language={langId}
onBlur={onBlur}
onChange={onChange}
containerStyles={styles.queryField}
monacoOptions={{
folding: false,
fontSize: 14,
lineNumbers: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
setupAutocompleteFn(editor, monaco);
setupPlaceholder(editor, monaco, styles);
setupAutoSize(editor);
}}
/>
{alertText && <TemporaryAlert severity="error" text={alertText} />}
</>
);
}
function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) {
const placeholderDecorators = [
{
range: new monaco.Range(1, 1, 1, 1),
options: {
className: styles.placeholder, // The placeholder text is in styles.placeholder
isWholeLine: true,
},
},
];
let decorators: string[] = [];
const checkDecorators = (): void => {
const model = editor.getModel();
if (!model) {
return;
}
const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : [];
decorators = model.deltaDecorations(decorators, newDecorators);
};
checkDecorators();
editor.onDidChangeModelContent(checkDecorators);
}
function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
const container = editor.getDomNode();
const updateHeight = () => {
if (container) {
const contentHeight = Math.min(1000, editor.getContentHeight());
const width = parseInt(container.style.width, 10);
container.style.width = `${width}px`;
container.style.height = `${contentHeight}px`;
editor.layout({ width, height: contentHeight });
}
};
editor.onDidContentSizeChange(updateHeight);
updateHeight();
}
/**
* Hook that returns function that will set up monaco autocomplete for the label selector
* @param datasource the Tempo datasource instance
* @param setAlertText setter for the alert text
*/
function useAutocomplete(datasource: TempoDatasource, setAlertText: (text?: string) => void) {
// We need the provider ref so we can pass it the label/values data later. This is because we run the call for the
// values here but there is additional setup needed for the provider later on. We could run the getSeries() in the
// returned function but that is run after the monaco is mounted so would delay the request a bit when it does not
// need to.
const providerRef = useRef<CompletionProvider>(
new CompletionProvider({ languageProvider: datasource.languageProvider })
);
useEffect(() => {
const fetchTags = async () => {
try {
await datasource.languageProvider.start();
setAlertText(undefined);
} catch (error) {
if (error instanceof Error) {
setAlertText(`Error: ${error.message}`);
}
}
};
fetchTags();
}, [datasource, setAlertText]);
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
useEffect(() => {
// when we unmount, we unregister the autocomplete-function, if it was registered
return () => {
autocompleteDisposeFun.current?.();
};
}, []);
// This should be run in monaco onEditorDidMount
return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
providerRef.current.editor = editor;
providerRef.current.monaco = monaco;
const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current);
autocompleteDisposeFun.current = dispose;
};
}
// we must only run the setup code once
let setupDone = false;
const langId = 'tagsfield';
function ensureTraceQL(monaco: Monaco) {
if (!setupDone) {
setupDone = true;
const { aliases, extensions, mimetypes, def } = languageDefinition;
monaco.languages.register({ id: langId, aliases, extensions, mimetypes });
monaco.languages.setMonarchTokensProvider(langId, def.language);
monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration);
}
}
interface EditorStyles {
placeholder: string;
queryField: string;
}
const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => {
return {
queryField: css({
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.components.input.borderColor}`,
flex: 1,
}),
placeholder: css({
'::after': {
content: `'${placeholder}'`,
fontFamily: theme.typography.fontFamilyMonospace,
opacity: 0.3,
},
}),
};
};

View File

@ -1,245 +0,0 @@
import { SelectableValue } from '@grafana/data';
import type { Monaco, monacoTypes } from '@grafana/ui';
import TempoLanguageProvider from '../../language_provider';
interface Props {
languageProvider: TempoLanguageProvider;
}
/**
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
* autocomplete system.
*/
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
languageProvider: TempoLanguageProvider;
constructor(props: Props) {
this.languageProvider = props.languageProvider;
}
triggerCharacters = ['=', ' '];
// We set these directly and ae required for the provider to function.
monaco: Monaco | undefined;
editor: monacoTypes.editor.IStandaloneCodeEditor | undefined;
private cachedValues: { [key: string]: Array<SelectableValue<string>> } = {};
provideCompletionItems(
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> {
// Should not happen, this should not be called before it is initialized
if (!(this.monaco && this.editor)) {
throw new Error('provideCompletionItems called before CompletionProvider was initialized');
}
// if the model-id does not match, then this call is from a different editor-instance,
// not "our instance", so return nothing
if (this.editor.getModel()?.id !== model.id) {
return { suggestions: [] };
}
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
const situation = this.getSituation(model.getValue(), offset);
const completionItems = this.getCompletions(situation);
return completionItems.then((items) => {
// monaco by-default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
// so that monaco keeps the order we use
const maxIndexDigits = items.length.toString().length;
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => {
const suggestion: monacoTypes.languages.CompletionItem = {
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
label: item.label,
insertText: item.insertText,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
};
return suggestion;
});
return { suggestions };
});
}
private async getTagValues(tagName: string): Promise<Array<SelectableValue<string>>> {
let tagValues: Array<SelectableValue<string>>;
if (this.cachedValues.hasOwnProperty(tagName)) {
tagValues = this.cachedValues[tagName];
} else {
tagValues = await this.languageProvider.getOptionsV1(tagName);
this.cachedValues[tagName] = tagValues;
}
return tagValues;
}
/**
* Get suggestion based on the situation we are in like whether we should suggest tag names or values.
* @param situation
* @private
*/
private async getCompletions(situation: Situation): Promise<Completion[]> {
switch (situation.type) {
// Not really sure what would make sense to suggest in this case so just leave it
case 'UNKNOWN': {
return [];
}
case 'EMPTY': {
return this.getTagsCompletions();
}
case 'IN_NAME':
return this.getTagsCompletions();
case 'IN_VALUE':
const tagValues = await this.getTagValues(situation.tagName);
const items: Completion[] = [];
const getInsertionText = (val: SelectableValue<string>): string => `"${val.label}"`;
tagValues.forEach((val) => {
if (val?.label) {
items.push({
label: val.label,
insertText: getInsertionText(val),
type: 'TAG_VALUE',
});
}
});
return items;
default:
throw new Error(`Unexpected situation ${situation}`);
}
}
private getTagsCompletions(): Completion[] {
const tags = this.languageProvider.getAutocompleteTags();
return tags
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' }))
.map((key) => ({
label: key,
insertText: key,
type: 'TAG_NAME',
}));
}
/**
* Figure out where is the cursor and what kind of suggestions are appropriate.
* @param text
* @param offset
*/
private getSituation(text: string, offset: number): Situation {
if (text === '' || offset === 0 || text[text.length - 1] === ' ') {
return {
type: 'EMPTY',
};
}
const textUntilCaret = text.substring(0, offset);
const regex = /(?<key>[^= ]+)(?<equals>=)?(?<value>([^ "]+)|"([^"]*)")?/;
const matches = textUntilCaret.match(new RegExp(regex, 'g'));
if (matches?.length) {
const last = matches[matches.length - 1];
const lastMatched = last.match(regex);
if (lastMatched) {
const key = lastMatched.groups?.key;
const equals = lastMatched.groups?.equals;
if (!key) {
return {
type: 'EMPTY',
};
}
if (!equals) {
return {
type: 'IN_NAME',
};
}
return {
type: 'IN_VALUE',
tagName: key,
};
}
}
return {
type: 'EMPTY',
};
}
}
/**
* Get item kind which is used for icon next to the suggestion.
* @param type
* @param monaco
*/
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'TAG_NAME':
return monaco.languages.CompletionItemKind.Enum;
case 'KEYWORD':
return monaco.languages.CompletionItemKind.Keyword;
case 'OPERATOR':
return monaco.languages.CompletionItemKind.Operator;
case 'TAG_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
case 'SCOPE':
return monaco.languages.CompletionItemKind.Class;
default:
throw new Error(`Unexpected CompletionType: ${type}`);
}
}
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
};
export type Tag = {
name: string;
value: string;
};
export type Situation =
| {
type: 'UNKNOWN';
}
| {
type: 'EMPTY';
}
| {
type: 'IN_NAME';
}
| {
type: 'IN_VALUE';
tagName: string;
};
function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) {
const word = model.getWordAtPosition(position);
const range =
word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
})
: monaco.Range.fromPositions(position);
// documentation says `position` will be "adjusted" in `getOffsetAt` so we clone it here just for sure.
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
const offset = model.getOffsetAt(positionClone);
return { offset, range };
}

View File

@ -1,124 +0,0 @@
import type { languages } from 'monaco-editor';
export const languageConfiguration: languages.LanguageConfiguration = {
// the default separators except `@$`
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
brackets: [
['{', '}'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
folding: {},
};
const operators = ['='];
export const language: languages.IMonarchLanguage = {
ignoreCase: false,
defaultToken: '',
tokenPostfix: '.tagsfield',
operators,
// we include these common regular expressions
symbols: /[=><!~?:&|+\-*\/^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
digits: /\d+(_+\d+)*/,
octaldigits: /[0-7]+(_+[0-7]+)*/,
binarydigits: /[0-1]+(_+[0-1]+)*/,
hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/,
floatsuffix: /[fFlL]?/,
tokenizer: {
root: [
// labels
[/[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/, 'tag'],
// all keywords have the same color
[
/[a-zA-Z_.]\w*/,
{
cases: {
'@default': 'identifier',
},
},
],
// strings
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
// whitespace
{ include: '@whitespace' },
// delimiters and operators
[/[{}()\[\]]/, '@brackets'],
[/[<>](?!@symbols)/, '@brackets'],
[
/@symbols/,
{
cases: {
'@operators': 'delimiter',
'@default': '',
},
},
],
// numbers
[/\d+/, 'number'],
[/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'],
[/0[0-7']*[0-7](@integersuffix)/, 'number.octal'],
[/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'],
[/\d[\d']*\d(@integersuffix)/, 'number'],
[/\d(@integersuffix)/, 'number'],
],
string_double: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/'/, 'string', '@pop'],
],
clauses: [
[/[^(,)]/, 'tag'],
[/\)/, 'identifier', '@pop'],
],
whitespace: [[/[ \t\r\n]+/, 'white']],
},
};
export const languageDefinition = {
id: 'tagsfield',
extensions: ['.tagsfield'],
aliases: ['tagsfield'],
mimetypes: [],
def: {
language,
languageConfiguration,
},
};

View File

@ -15,13 +15,13 @@ import {
withTheme2,
} from '@grafana/ui';
import NativeSearch from './NativeSearch/NativeSearch';
import TraceQLSearch from './SearchTraceQLEditor/TraceQLSearch';
import { ServiceGraphSection } from './ServiceGraphSection';
import { TempoQueryType } from './dataquery.gen';
import { TempoDatasource } from './datasource';
import { QueryEditor } from './traceql/QueryEditor';
import { TempoQuery } from './types';
import { migrateFromSearchToTraceQLSearch } from './utils';
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {}
interface State {
@ -74,7 +74,7 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
{ value: 'serviceMap', label: 'Service Graph' },
];
// Show the deprecated search option if any of the deprecated search fields are set
// Migrate user to new query type if they are using the old search query type
if (
query.spanName ||
query.serviceName ||
@ -83,7 +83,7 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
query.minDuration ||
query.queryType === 'nativeSearch'
) {
queryTypeOptions.unshift({ value: 'nativeSearch', label: '[Deprecated] Search' });
onChange(migrateFromSearchToTraceQLSearch(query));
}
return (
@ -146,15 +146,6 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
</HorizontalGroup>
</InlineField>
</InlineFieldRow>
{query.queryType === 'nativeSearch' && (
<NativeSearch
datasource={this.props.datasource}
query={query}
onChange={onChange}
onBlur={this.props.onBlur}
onRunQuery={this.props.onRunQuery}
/>
)}
{query.queryType === 'traceqlSearch' && (
<TraceQLSearch
datasource={this.props.datasource}

View File

@ -54,7 +54,6 @@ const TagsInput = ({
query,
}: Props) => {
const styles = useStyles2(getStyles);
const generateId = () => uuidv4().slice(0, 8);
const handleOnAdd = useCallback(
() => updateFilter({ id: generateId(), operator: '=', scope: TraceqlSearchScope.Span }),
[updateFilter]
@ -117,3 +116,5 @@ const TagsInput = ({
};
export default TagsInput;
export const generateId = () => uuidv4().slice(0, 8);

View File

@ -53,7 +53,6 @@ composableKinds: DataQuery: {
tableType?: #SearchTableType
} @cuetsy(kind="interface") @grafana(TSVeneer="type")
// nativeSearch = Tempo search for backwards compatibility
#TempoQueryType: "traceql" | "traceqlSearch" | "serviceMap" | "upload" | "nativeSearch" | "traceId" | "clear" @cuetsy(kind="type")
// The state of the TraceQL streaming search query

View File

@ -67,9 +67,6 @@ export const defaultTempoQuery: Partial<TempoQuery> = {
groupBy: [],
};
/**
* nativeSearch = Tempo search for backwards compatibility
*/
export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear');
/**

View File

@ -31,7 +31,6 @@ import { TempoVariableQueryType } from './VariableQueryEditor';
import { createFetchResponse } from './_importedDependencies/test/helpers/createFetchResponse';
import { TraceqlSearchScope } from './dataquery.gen';
import {
DEFAULT_LIMIT,
TempoDatasource,
buildExpr,
buildLinkExpr,
@ -83,11 +82,6 @@ describe('Tempo data source', () => {
refId: 'x',
queryType: 'traceql',
query: '$interpolationVarWithPipe',
spanName: '$interpolationVar',
serviceName: '$interpolationVar',
search: '$interpolationVar',
minDuration: '$interpolationVar',
maxDuration: '$interpolationVar',
serviceMapQuery,
filters: [
{
@ -135,11 +129,6 @@ describe('Tempo data source', () => {
const ds = new TempoDatasource(defaultSettings, templateSrv);
const queries = ds.interpolateVariablesInQueries([getQuery()], {});
expect(queries[0].query).toBe(textWithPipe);
expect(queries[0].serviceName).toBe(text);
expect(queries[0].spanName).toBe(text);
expect(queries[0].search).toBe(text);
expect(queries[0].minDuration).toBe(text);
expect(queries[0].maxDuration).toBe(text);
expect(queries[0].serviceMapQuery).toBe(text);
expect(queries[0].filters[0].value).toBe(textWithPipe);
expect(queries[0].filters[1].value).toBe(text);
@ -153,11 +142,6 @@ describe('Tempo data source', () => {
interpolationVar: { text: scopedText, value: scopedText },
});
expect(resp.query).toBe(textWithPipe);
expect(resp.serviceName).toBe(scopedText);
expect(resp.spanName).toBe(scopedText);
expect(resp.search).toBe(scopedText);
expect(resp.minDuration).toBe(scopedText);
expect(resp.maxDuration).toBe(scopedText);
expect(resp.filters[0].value).toBe(textWithPipe);
expect(resp.filters[1].value).toBe(scopedText);
expect(resp.filters[1].tag).toBe(scopedText);
@ -283,31 +267,6 @@ describe('Tempo data source', () => {
expect(edgesFrame.meta?.preferredVisualisationType).toBe('nodeGraph');
});
it('should build search query correctly', () => {
const duration = '10ms';
const templateSrv = { replace: jest.fn().mockReturnValue(duration) } as unknown as TemplateSrv;
const ds = new TempoDatasource(defaultSettings, templateSrv);
const tempoQuery: TempoQuery = {
queryType: 'nativeSearch',
refId: 'A',
query: '',
serviceName: 'frontend',
spanName: '/config',
search: 'root.http.status_code=500',
minDuration: '$interpolationVar',
maxDuration: '$interpolationVar',
limit: 10,
filters: [],
};
const builtQuery = ds.buildSearchQuery(tempoQuery);
expect(builtQuery).toStrictEqual({
tags: 'root.http.status_code=500 service.name="frontend" name="/config"',
minDuration: duration,
maxDuration: duration,
limit: 10,
});
});
it('should format metrics summary query correctly', () => {
const ds = new TempoDatasource(defaultSettings, {} as TemplateSrv);
const queryGroupBy = [
@ -320,61 +279,6 @@ describe('Tempo data source', () => {
expect(groupBy).toEqual('.component, span.name, resource.service.name, kind');
});
it('should include a default limit', () => {
const ds = new TempoDatasource(defaultSettings);
const tempoQuery: TempoQuery = {
queryType: 'nativeSearch',
refId: 'A',
query: '',
search: '',
filters: [],
};
const builtQuery = ds.buildSearchQuery(tempoQuery);
expect(builtQuery).toStrictEqual({
tags: '',
limit: DEFAULT_LIMIT,
});
});
it('should include time range if provided', () => {
const ds = new TempoDatasource(defaultSettings);
const tempoQuery: TempoQuery = {
queryType: 'nativeSearch',
refId: 'A',
query: '',
search: '',
filters: [],
};
const timeRange = { startTime: 0, endTime: 1000 };
const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange);
expect(builtQuery).toStrictEqual({
tags: '',
limit: DEFAULT_LIMIT,
start: timeRange.startTime,
end: timeRange.endTime,
});
});
it('formats native search query history correctly', () => {
const ds = new TempoDatasource(defaultSettings);
const tempoQuery: TempoQuery = {
filters: [],
queryType: 'nativeSearch',
refId: 'A',
query: '',
serviceName: 'frontend',
spanName: '/config',
search: 'root.http.status_code=500',
minDuration: '1ms',
maxDuration: '100s',
limit: 10,
};
const result = ds.getQueryDisplayText(tempoQuery);
expect(result).toBe(
'Service Name: frontend, Span Name: /config, Search: root.http.status_code=500, Min Duration: 1ms, Max Duration: 100s, Limit: 10'
);
});
describe('test the testDatasource function', () => {
it('should return a success msg if response.ok is true', async () => {
mockObservable = () => of({ ok: true });

View File

@ -1,4 +1,4 @@
import { groupBy, identity, pick, pickBy, startCase } from 'lodash';
import { groupBy, startCase } from 'lodash';
import { EMPTY, from, lastValueFrom, merge, Observable, of } from 'rxjs';
import { catchError, concatMap, map, mergeMap, toArray } from 'rxjs/operators';
import semver from 'semver';
@ -14,7 +14,6 @@ import {
DataSourceInstanceSettings,
dateTime,
FieldType,
isValidGoDuration,
LoadingState,
rangeUtil,
ScopedVars,
@ -53,15 +52,14 @@ import {
import TempoLanguageProvider from './language_provider';
import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary';
import {
createTableFrameFromSearch,
formatTraceQLMetrics,
formatTraceQLResponse,
transformFromOTLP as transformFromOTEL,
transformTrace,
} from './resultTransformer';
import { doTempoChannelStream } from './streaming';
import { SearchQueryParams, TempoJsonData, TempoQuery } from './types';
import { getErrorMessage } from './utils';
import { TempoJsonData, TempoQuery } from './types';
import { getErrorMessage, migrateFromSearchToTraceQLSearch } from './utils';
import { TempoVariableSupport } from './variables';
export const DEFAULT_LIMIT = 20;
@ -265,37 +263,18 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return of({ data: [], state: LoadingState.Done });
}
// Migrate user to new query type if they are using the old search query type
if (targets.nativeSearch?.length) {
try {
reportInteraction('grafana_traces_search_queried', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
hasServiceName: targets.nativeSearch[0].serviceName ? true : false,
hasSpanName: targets.nativeSearch[0].spanName ? true : false,
resultLimit: targets.nativeSearch[0].limit ?? '',
hasSearch: targets.nativeSearch[0].search ? true : false,
minDuration: targets.nativeSearch[0].minDuration ?? '',
maxDuration: targets.nativeSearch[0].maxDuration ?? '',
});
const timeRange = { startTime: options.range.from.unix(), endTime: options.range.to.unix() };
const query = this.applyVariables(targets.nativeSearch[0], options.scopedVars);
const searchQuery = this.buildSearchQuery(query, timeRange);
subQueries.push(
this._request('/api/search', searchQuery).pipe(
map((response) => {
return {
data: [createTableFrameFromSearch(response.data.traces, this.instanceSettings)],
};
}),
catchError((err) => {
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
})
)
);
} catch (error) {
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
if (
targets.nativeSearch[0].spanName ||
targets.nativeSearch[0].serviceName ||
targets.nativeSearch[0].search ||
targets.nativeSearch[0].maxDuration ||
targets.nativeSearch[0].minDuration ||
targets.nativeSearch[0].queryType === 'nativeSearch'
) {
const migratedQuery = migrateFromSearchToTraceQLSearch(targets.nativeSearch[0]);
targets.traceqlSearch = [migratedQuery];
}
}
@ -502,11 +481,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return {
...expandedQuery,
query: this.templateSrv.replace(query.query ?? '', scopedVars, VariableFormatID.Pipe),
serviceName: this.templateSrv.replace(query.serviceName ?? '', scopedVars),
spanName: this.templateSrv.replace(query.spanName ?? '', scopedVars),
search: this.templateSrv.replace(query.search ?? '', scopedVars),
minDuration: this.templateSrv.replace(query.minDuration ?? '', scopedVars),
maxDuration: this.templateSrv.replace(query.maxDuration ?? '', scopedVars),
serviceMapQuery: Array.isArray(query.serviceMapQuery)
? query.serviceMapQuery.map((query) => this.templateSrv.replace(query, scopedVars))
: this.templateSrv.replace(query.serviceMapQuery ?? '', scopedVars),
@ -758,55 +732,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
.map((key) => `${startCase(key)}: ${query[key]}`)
.join(', ');
}
buildSearchQuery(query: TempoQuery, timeRange?: { startTime: number; endTime?: number }): SearchQueryParams {
let tags = query.search ?? '';
let tempoQuery = pick(query, ['minDuration', 'maxDuration', 'limit']);
// Remove empty properties
tempoQuery = pickBy(tempoQuery, identity);
if (query.serviceName) {
tags += ` service.name="${query.serviceName}"`;
}
if (query.spanName) {
tags += ` name="${query.spanName}"`;
}
// Set default limit
if (!tempoQuery.limit) {
tempoQuery.limit = DEFAULT_LIMIT;
}
// Validate query inputs and remove spaces if valid
if (tempoQuery.minDuration) {
tempoQuery.minDuration = this.templateSrv.replace(tempoQuery.minDuration ?? '');
if (!isValidGoDuration(tempoQuery.minDuration)) {
throw new Error('Please enter a valid min duration.');
}
tempoQuery.minDuration = tempoQuery.minDuration.replace(/\s/g, '');
}
if (tempoQuery.maxDuration) {
tempoQuery.maxDuration = this.templateSrv.replace(tempoQuery.maxDuration ?? '');
if (!isValidGoDuration(tempoQuery.maxDuration)) {
throw new Error('Please enter a valid max duration.');
}
tempoQuery.maxDuration = tempoQuery.maxDuration.replace(/\s/g, '');
}
if (!Number.isInteger(tempoQuery.limit) || tempoQuery.limit <= 0) {
throw new Error('Please enter a valid limit.');
}
let searchQuery: SearchQueryParams = { tags, ...tempoQuery };
if (timeRange) {
searchQuery.start = timeRange.startTime;
searchQuery.end = timeRange.endTime;
}
return searchQuery;
}
}
function queryPrometheus(request: DataQueryRequest<PromQuery>, datasourceUid: string) {

View File

@ -48,7 +48,7 @@
"title": "View traces",
"internal": {
"query": {
"queryType": "nativeSearch",
"queryType": "traceqlSearch",
"serviceName": "${__data.fields[0]}"
},
"datasourceUid": "TNS Tempo",

View File

@ -1,11 +1,10 @@
import { collectorTypes } from '@opentelemetry/exporter-collector';
import { PluginType, DataSourceInstanceSettings, dateTime, PluginMetaInfo } from '@grafana/data';
import { PluginType, DataSourceInstanceSettings, PluginMetaInfo } from '@grafana/data';
import {
transformToOTLP,
transformFromOTLP,
createTableFrameFromSearch,
createTableFrameFromTraceQlQuery,
createTableFrameFromTraceQlQueryAsSpans,
} from './resultTransformer';
@ -14,7 +13,6 @@ import {
otlpDataFrameToResponse,
otlpDataFrameFromResponse,
otlpResponse,
tempoSearchResponse,
traceQlResponse,
} from './testResponse';
import { TraceSearchMetadata } from './types';
@ -57,32 +55,6 @@ describe('transformFromOTLP()', () => {
});
});
describe('createTableFrameFromSearch()', () => {
const mockTimeUnix = dateTime(1643357709095).valueOf();
global.Date.now = jest.fn(() => mockTimeUnix);
test('transforms search response to dataFrame', () => {
const frame = createTableFrameFromSearch(tempoSearchResponse.traces as TraceSearchMetadata[], defaultSettings);
expect(frame.fields[0].name).toBe('traceID');
expect(frame.fields[0].values[0]).toBe('e641dcac1c3a0565');
// TraceID must have unit = 'string' to prevent the ID from rendering as Infinity
expect(frame.fields[0].config.unit).toBe('string');
expect(frame.fields[1].name).toBe('traceService');
expect(frame.fields[1].values[0]).toBe('requester');
expect(frame.fields[2].name).toBe('traceName');
expect(frame.fields[2].values[0]).toBe('app');
expect(frame.fields[3].name).toBe('startTime');
expect(frame.fields[3].values[0]).toBe(1643356828724);
expect(frame.fields[3].values[1]).toBe(1643342166678.0002);
expect(frame.fields[4].name).toBe('traceDuration');
expect(frame.fields[4].values[0]).toBe(65);
});
});
describe('createTableFrameFromTraceQlQuery()', () => {
test('transforms TraceQL response to DataFrame', () => {
const frameList = createTableFrameFromTraceQlQuery(traceQlResponse.traces, defaultSettings);

View File

@ -464,63 +464,6 @@ export function transformTrace(
};
}
export function createTableFrameFromSearch(data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings) {
const frame = new MutableDataFrame({
name: 'Traces',
refId: 'traces',
fields: [
{
name: 'traceID',
type: FieldType.string,
values: [],
config: {
unit: 'string',
displayNameFromDS: 'Trace ID',
links: [
{
title: 'Trace: ${__value.raw}',
url: '',
internal: {
datasourceUid: instanceSettings.uid,
datasourceName: instanceSettings.name,
query: {
query: '${__value.raw}',
queryType: 'traceql',
},
},
},
],
},
},
{ name: 'traceService', type: FieldType.string, config: { displayNameFromDS: 'Trace service' }, values: [] },
{ name: 'traceName', type: FieldType.string, config: { displayNameFromDS: 'Trace name' }, values: [] },
{ name: 'startTime', type: FieldType.time, config: { displayNameFromDS: 'Start time' }, values: [] },
{
name: 'traceDuration',
type: FieldType.number,
config: { displayNameFromDS: 'Duration', unit: 'ms' },
values: [],
},
],
meta: {
preferredVisualisationType: 'table',
},
});
if (!data?.length) {
return frame;
}
// Show the most recent traces
const traceData = data
.sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000)
.map(transformToTraceData);
for (const trace of traceData) {
frame.add(trace);
}
return frame;
}
function transformToTraceData(data: TraceSearchMetadata) {
return {
traceID: data.traceID,

View File

@ -2273,28 +2273,6 @@ export const otlpResponse = {
],
};
export const tempoSearchResponse = {
traces: [
{
traceID: 'e641dcac1c3a0565',
rootServiceName: 'requester',
rootTraceName: 'app',
startTimeUnixNano: '1643356828724000000',
durationMs: 65,
},
{
traceID: 'c2983496a2b12544',
rootServiceName: '<root span not yet received>',
startTimeUnixNano: '1643342166678000000',
durationMs: 93,
},
],
metrics: {
inspectedTraces: 2,
inspectedBytes: '83720',
},
};
export const traceQlResponse = {
traces: [
{

View File

@ -17,23 +17,6 @@ jest.mock('@grafana/runtime', () => {
grafanaVersion: 'v9.4.0',
queries: {
tempo: [
{
datasource: { type: 'tempo', uid: 'abc' },
queryType: 'nativeSearch',
refId: 'A',
},
{
datasource: { type: 'tempo', uid: 'abc' },
queryType: 'nativeSearch',
spanName: 'HTTP',
refId: 'A',
},
{
datasource: { type: 'tempo', uid: 'abc' },
queryType: 'nativeSearch',
spanName: '$var',
refId: 'A',
},
{
datasource: { type: 'tempo', uid: 'abc' },
queryType: 'serviceMap',
@ -86,10 +69,8 @@ describe('on dashboard loaded', () => {
traceql_query_count: 2,
service_map_query_count: 2,
upload_query_count: 1,
native_search_query_count: 3,
traceql_queries_with_template_variables_count: 1,
service_map_queries_with_template_variables_count: 1,
native_search_queries_with_template_variables_count: 1,
});
});
});

View File

@ -8,11 +8,9 @@ type TempoOnDashboardLoadedTrackingEvent = {
grafana_version?: string;
dashboard_id?: string;
org_id?: number;
native_search_query_count: number;
service_map_query_count: number;
traceql_query_count: number;
upload_query_count: number;
native_search_queries_with_template_variables_count: number;
service_map_queries_with_template_variables_count: number;
traceql_queries_with_template_variables_count: number;
};
@ -31,11 +29,9 @@ export const onDashboardLoadedHandler = ({
grafana_version: grafanaVersion,
dashboard_id: dashboardId,
org_id: orgId,
native_search_query_count: 0,
service_map_query_count: 0,
traceql_query_count: 0,
upload_query_count: 0,
native_search_queries_with_template_variables_count: 0,
service_map_queries_with_template_variables_count: 0,
traceql_queries_with_template_variables_count: 0,
};
@ -45,18 +41,7 @@ export const onDashboardLoadedHandler = ({
continue;
}
if (query.queryType === 'nativeSearch') {
stats.native_search_query_count++;
if (
(query.serviceName && hasTemplateVariables(query.serviceName)) ||
(query.spanName && hasTemplateVariables(query.spanName)) ||
(query.search && hasTemplateVariables(query.search)) ||
(query.minDuration && hasTemplateVariables(query.minDuration)) ||
(query.maxDuration && hasTemplateVariables(query.maxDuration))
) {
stats.native_search_queries_with_template_variables_count++;
}
} else if (query.queryType === 'serviceMap') {
if (query.queryType === 'serviceMap') {
stats.service_map_query_count++;
if (query.serviceMapQuery && hasTemplateVariables(query.serviceMapQuery)) {
stats.service_map_queries_with_template_variables_count++;

View File

@ -3,15 +3,6 @@ import { NodeGraphOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'
import { TempoQuery as TempoBase, TempoQueryType, TraceqlFilter } from './dataquery.gen';
export interface SearchQueryParams {
minDuration?: string;
maxDuration?: string;
limit?: number;
tags?: string;
start?: number;
end?: number;
}
export interface TempoJsonData extends DataSourceJsonData {
tracesToLogs?: TraceToLogsOptions;
serviceMap?: {

View File

@ -0,0 +1,51 @@
import { TempoQuery } from './types';
import { migrateFromSearchToTraceQLSearch } from './utils';
describe('utils', () => {
it('migrateFromSearchToTraceQLSearch correctly updates the query', async () => {
const query: TempoQuery = {
refId: 'A',
filters: [],
queryType: 'nativeSearch',
serviceName: 'frontend',
spanName: 'http.server',
minDuration: '1s',
maxDuration: '10s',
search: 'component="net/http" datasource.type="tempo"',
};
const migratedQuery = migrateFromSearchToTraceQLSearch(query);
expect(migratedQuery.queryType).toBe('traceqlSearch');
expect(migratedQuery.filters.length).toBe(7);
expect(migratedQuery.filters[0].scope).toBe('span');
expect(migratedQuery.filters[0].tag).toBe('name');
expect(migratedQuery.filters[0].operator).toBe('=');
expect(migratedQuery.filters[0].value![0]).toBe('http.server');
expect(migratedQuery.filters[0].valueType).toBe('string');
expect(migratedQuery.filters[1].scope).toBe('resource');
expect(migratedQuery.filters[1].tag).toBe('service.name');
expect(migratedQuery.filters[1].operator).toBe('=');
expect(migratedQuery.filters[1].value![0]).toBe('frontend');
expect(migratedQuery.filters[1].valueType).toBe('string');
expect(migratedQuery.filters[2].id).toBe('duration-type');
expect(migratedQuery.filters[2].value).toBe('trace');
expect(migratedQuery.filters[3].tag).toBe('duration');
expect(migratedQuery.filters[3].operator).toBe('>');
expect(migratedQuery.filters[3].value![0]).toBe('1s');
expect(migratedQuery.filters[3].valueType).toBe('duration');
expect(migratedQuery.filters[4].tag).toBe('duration');
expect(migratedQuery.filters[4].operator).toBe('<');
expect(migratedQuery.filters[4].value![0]).toBe('10s');
expect(migratedQuery.filters[4].valueType).toBe('duration');
expect(migratedQuery.filters[5].scope).toBe('unscoped');
expect(migratedQuery.filters[5].tag).toBe('component');
expect(migratedQuery.filters[5].operator).toBe('=');
expect(migratedQuery.filters[5].value![0]).toBe('net/http');
expect(migratedQuery.filters[5].valueType).toBe('string');
expect(migratedQuery.filters[6].scope).toBe('unscoped');
expect(migratedQuery.filters[6].tag).toBe('datasource.type');
expect(migratedQuery.filters[6].operator).toBe('=');
expect(migratedQuery.filters[6].value![0]).toBe('tempo');
expect(migratedQuery.filters[6].valueType).toBe('string');
});
});

View File

@ -1,6 +1,10 @@
import { DataSourceApi } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { generateId } from './SearchTraceQLEditor/TagsInput';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import { TempoQuery } from './types';
export const getErrorMessage = (message: string | undefined, prefix?: string) => {
const err = message ? ` (${message})` : '';
let errPrefix = prefix ? prefix : 'Error';
@ -20,3 +24,78 @@ export async function getDS(uid?: string): Promise<DataSourceApi | undefined> {
return undefined;
}
}
export const migrateFromSearchToTraceQLSearch = (query: TempoQuery) => {
let filters: TraceqlFilter[] = [];
if (query.spanName) {
filters.push({
id: 'span-name',
scope: TraceqlSearchScope.Span,
tag: 'name',
operator: '=',
value: [query.spanName],
valueType: 'string',
});
}
if (query.serviceName) {
filters.push({
id: 'service-name',
scope: TraceqlSearchScope.Resource,
tag: 'service.name',
operator: '=',
value: [query.serviceName],
valueType: 'string',
});
}
if (query.minDuration || query.maxDuration) {
filters.push({
id: 'duration-type',
value: 'trace',
});
}
if (query.minDuration) {
filters.push({
id: 'min-duration',
tag: 'duration',
operator: '>',
value: [query.minDuration],
valueType: 'duration',
});
}
if (query.maxDuration) {
filters.push({
id: 'max-duration',
tag: 'duration',
operator: '<',
value: [query.maxDuration],
valueType: 'duration',
});
}
if (query.search) {
const tags = query.search.split(' ');
for (const tag of tags) {
const [key, value] = tag.split('=');
if (key && value) {
filters.push({
id: generateId(),
scope: TraceqlSearchScope.Unscoped,
tag: key,
operator: '=',
value: [value.replace(/(^"|"$)/g, '')], // remove quotes at start and end of string
valueType: value.startsWith('"') && value.endsWith('"') ? 'string' : undefined,
});
}
}
}
const migratedQuery: TempoQuery = {
datasource: query.datasource,
filters,
groupBy: query.groupBy,
limit: query.limit,
query: query.query,
queryType: 'traceqlSearch',
refId: query.refId,
};
return migratedQuery;
};