Tempo: Basic PoC of the upcoming TraceQL editor in Explore (#54028)

* First working version of the TraceQL editor with syntax highlighting and autocomplete

* Add feature flag around the new editor option

* Fix tests and cleanup

* Fix misspelling
This commit is contained in:
Andre Pereira 2022-08-24 17:57:59 +01:00 committed by GitHub
parent 94b4f6f459
commit c8f2148f75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 687 additions and 9 deletions

View File

@ -63,4 +63,5 @@ export interface FeatureToggles {
internationalization?: boolean;
topnav?: boolean;
customBranding?: boolean;
traceqlEditor?: boolean;
}

View File

@ -262,5 +262,10 @@ var (
Description: "Replaces whitelabeling with the new custom branding feature",
State: FeatureStateAlpha,
},
{
Name: "traceqlEditor",
Description: "Show the TraceQL editor in the explore page",
State: FeatureStateAlpha,
},
}
)

View File

@ -194,4 +194,8 @@ const (
// FlagCustomBranding
// Replaces whitelabeling with the new custom branding feature
FlagCustomBranding = "customBranding"
// FlagTraceqlEditor
// Show the TraceQL editor in the explore page
FlagTraceqlEditor = "traceqlEditor"
)

View File

@ -3,7 +3,7 @@ import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import {
FileDropzone,
InlineField,
@ -19,6 +19,7 @@ import { LokiQueryField } from '../../loki/components/LokiQueryField';
import { LokiDatasource } from '../../loki/datasource';
import { LokiQuery } from '../../loki/types';
import { TempoDatasource, TempoQuery, TempoQueryType } from '../datasource';
import { QueryEditor } from '../traceql/QueryEditor';
import NativeSearch from './NativeSearch';
import { ServiceGraphSection } from './ServiceGraphSection';
@ -95,6 +96,10 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
}
}
if (config.featureToggles.traceqlEditor) {
queryTypeOptions.push({ value: 'traceql', label: 'TraceQL' });
}
return (
<>
<InlineFieldRow>
@ -173,6 +178,14 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
{query.queryType === 'serviceMap' && (
<ServiceGraphSection graphDatasourceUid={graphDatasourceUid} query={query} onChange={onChange} />
)}
{query.queryType === 'traceql' && (
<QueryEditor
datasource={this.props.datasource}
query={query}
onRunQuery={this.props.onRunQuery}
onChange={onChange}
/>
)}
</>
);
}

View File

@ -45,6 +45,7 @@ import {
errorRateMetric,
defaultTableFilter,
} from './graphTransform';
import TempoLanguageProvider from './language_provider';
import {
transformTrace,
transformTraceList,
@ -53,7 +54,7 @@ import {
} from './resultTransformer';
// search = Loki search, nativeSearch = Tempo search for backwards compatibility
export type TempoQueryType = 'search' | 'traceId' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear';
export type TempoQueryType = 'traceql' | 'search' | 'traceId' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear';
export interface TempoJsonData extends DataSourceJsonData {
tracesToLogs?: TraceToLogsOptions;
@ -111,6 +112,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
};
uploadedJson?: string | ArrayBuffer | null = null;
spanBar?: SpanBarOptions;
languageProvider: TempoLanguageProvider;
constructor(
private instanceSettings: DataSourceInstanceSettings<TempoJsonData>,
@ -122,6 +124,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
this.search = instanceSettings.jsonData.search;
this.nodeGraph = instanceSettings.jsonData.nodeGraph;
this.lokiSearch = instanceSettings.jsonData.lokiSearch;
this.languageProvider = new TempoLanguageProvider(this);
}
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {

View File

@ -1,6 +1,6 @@
import { Value } from 'slate';
import { HistoryItem, LanguageProvider, SelectableValue } from '@grafana/data';
import { LanguageProvider, SelectableValue } from '@grafana/data';
import { CompletionItemGroup, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
import { TempoDatasource } from './datasource';
@ -21,8 +21,13 @@ export default class TempoLanguageProvider extends LanguageProvider {
};
start = async () => {
await this.fetchTags();
return [];
if (!this.startTask) {
this.startTask = this.fetchTags().then(() => {
return [];
});
}
return this.startTask;
};
async fetchTags() {
@ -30,10 +35,11 @@ export default class TempoLanguageProvider extends LanguageProvider {
this.tags = response.tagNames;
}
provideCompletionItems = async (
{ prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput,
context: { history: Array<HistoryItem<any>> } = { history: [] }
): Promise<TypeaheadOutput> => {
getTags = () => {
return this.tags;
};
provideCompletionItems = async ({ text, value }: TypeaheadInput): Promise<TypeaheadOutput> => {
const emptyResult: TypeaheadOutput = { suggestions: [] };
if (!value) {

View File

@ -0,0 +1,25 @@
import { defaults } from 'lodash';
import React from 'react';
import { QueryEditorProps } from '@grafana/data';
import { TempoDatasource, TempoQuery } from '../datasource';
import { defaultQuery, MyDataSourceOptions } from '../types';
import { TraceQLEditor } from './TraceQLEditor';
type Props = QueryEditorProps<TempoDatasource, TempoQuery, MyDataSourceOptions>;
export function QueryEditor(props: Props) {
function onEditorChange(value: string) {
props.onChange({ ...props.query, query: value });
}
let query = defaults(props.query, defaultQuery);
return (
<div className="gf-form">
<TraceQLEditor value={query.query} onChange={onEditorChange} datasource={props.datasource} />
</div>
);
}

View File

@ -0,0 +1,126 @@
import { css } from '@emotion/css';
import type { languages } from 'monaco-editor';
import React, { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, Monaco, useStyles2, monacoTypes } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { TempoDatasource } from '../datasource';
import { CompletionProvider } from './autocomplete';
import { languageDefinition } from './traceql';
interface Props {
value: string;
onChange: (val: string) => void;
datasource: TempoDatasource;
}
export function TraceQLEditor(props: Props) {
const setupAutocompleteFn = useAutocomplete(props.datasource);
const styles = useStyles2(getStyles);
return (
<CodeEditor
value={props.value}
language={langId}
onBlur={props.onChange}
height={'30px'}
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);
}}
/>
);
}
/**
* Hook that returns function that will set up monaco autocomplete for the label selector
* @param datasource
*/
function useAutocomplete(datasource: TempoDatasource) {
// 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();
const tags = datasource.languageProvider.getTags();
if (tags) {
providerRef.current.setTags(tags);
}
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchTags();
}, [datasource]);
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 traceqlSetupDone = false;
const langId = 'traceql';
function ensureTraceQL(monaco: Monaco) {
if (!traceqlSetupDone) {
traceqlSetupDone = true;
const { aliases, extensions, mimetypes, def } = languageDefinition;
monaco.languages.register({ id: langId, aliases, extensions, mimetypes });
monaco.languages.setMonarchTokensProvider(langId, def.language as languages.IMonarchLanguage);
monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration as languages.LanguageConfiguration);
}
}
const getStyles = (theme: GrafanaTheme2) => {
return {
queryField: css`
border-radius: ${theme.shape.borderRadius()};
border: 1px solid ${theme.components.input.borderColor};
flex: 1;
`,
};
};

View File

@ -0,0 +1,147 @@
import { DataSourceInstanceSettings, PluginType } from '@grafana/data/src';
import { monacoTypes } from '@grafana/ui';
import { TempoDatasource, TempoJsonData } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { CompletionProvider } from './autocomplete';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
describe('CompletionProvider', () => {
it('suggests tags', async () => {
const { provider, model } = setup('{}', 1, defaultTags);
const result = await provider.provideCompletionItems(model as any, {} as any);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foo', insertText: 'foo' }),
expect.objectContaining({ label: 'bar', insertText: 'bar' }),
]);
});
it('suggests tag names with quotes', async () => {
const { provider, model } = setup('{foo=}', 6, defaultTags);
jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model as any, {} as any);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: '"foobar"' }),
]);
});
it('suggests tag names without quotes', async () => {
const { provider, model } = setup('{foo="}', 7, defaultTags);
jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model as any, {} as any);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
});
it('suggests nothing without tags', async () => {
const { provider, model } = setup('{foo="}', 7, []);
const result = await provider.provideCompletionItems(model as any, {} as any);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests tags on empty input', async () => {
const { provider, model } = setup('', 0, defaultTags);
const result = await provider.provideCompletionItems(model as any, {} as any);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foo', insertText: '{foo="' }),
expect.objectContaining({ label: 'bar', insertText: '{bar="' }),
]);
});
});
const defaultTags = ['foo', 'bar'];
function setup(value: string, offset: number, tags?: string[]) {
const ds = new TempoDatasource(defaultSettings);
const provider = new CompletionProvider({ languageProvider: new TempoLanguageProvider(ds) });
if (tags) {
provider.setTags(tags);
}
const model = makeModel(value, offset);
provider.monaco = {
Range: {
fromPositions() {
return null;
},
},
languages: {
CompletionItemKind: {
Enum: 1,
EnumMember: 2,
},
},
} as any;
provider.editor = {
getModel() {
return model;
},
} as any;
return { provider, model };
}
function makeModel(value: string, offset: number) {
return {
id: 'test_monaco',
getWordAtPosition() {
return null;
},
getOffsetAt() {
return offset;
},
getValue() {
return value;
},
};
}
const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
id: 0,
uid: 'gdev-tempo',
type: 'tracing',
name: 'tempo',
access: 'proxy',
meta: {
id: 'tempo',
name: 'tempo',
type: PluginType.datasource,
info: {} as any,
module: '',
baseUrl: '',
},
jsonData: {
nodeGraph: {
enabled: true,
},
},
};

View File

@ -0,0 +1,235 @@
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 tags: { [tag: string]: Set<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 = 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) => ({
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 { suggestions };
});
}
/**
* We expect the tags list data directly from the request and assign it an empty set here.
*/
setTags(tags: string[]) {
tags.forEach((t) => (this.tags[t] = new Set<string>()));
}
/**
* 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[]> {
if (!Object.keys(this.tags).length) {
return [];
}
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 Object.keys(this.tags).map((key) => {
return {
label: key,
insertText: `{${key}="`,
type: 'TAG_NAME',
};
});
}
case 'IN_TAG_NAME':
return Object.keys(this.tags).map((key) => {
return {
label: key,
insertText: key,
type: 'TAG_NAME',
};
});
case 'IN_TAG_VALUE':
return await this.languageProvider.getOptions(situation.tagName).then((res) => {
const items: Completion[] = [];
res.forEach((val) => {
if (val?.label) {
items.push({
label: val.label,
insertText: situation.betweenQuotes ? val.label : `"${val.label}"`,
type: 'TAG_VALUE',
});
}
});
return items;
});
default:
throw new Error(`Unexpected situation ${situation}`);
}
}
}
/**
* 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 'TAG_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
default:
throw new Error(`Unexpected CompletionType: ${type}`);
}
}
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
};
export type Tag = {
name: string;
value: string;
};
export type Situation =
| {
type: 'UNKNOWN';
}
| {
type: 'EMPTY';
}
| {
type: 'IN_TAG_NAME';
otherTags: Tag[];
}
| {
type: 'IN_TAG_VALUE';
tagName: string;
betweenQuotes: boolean;
otherTags: Tag[];
};
/**
* Figure out where is the cursor and what kind of suggestions are appropriate.
* As currently TraceQL handles just a simple {foo="bar", baz="zyx"} kind of values we can do with simple regex to figure
* out where we are with the cursor.
* @param text
* @param offset
*/
function getSituation(text: string, offset: number): Situation {
if (text === '') {
return {
type: 'EMPTY',
};
}
// Get all the tags so far in the query so we can do some more filtering.
const matches = text.matchAll(/(\w+)="(\w+)"/g);
const existingTags = Array.from(matches).reduce((acc, match) => {
const [_, name, value] = match[1];
acc.push({ name, value });
return acc;
}, [] as Tag[]);
// Check if we are editing a tag value right now. If so also get name of the tag
const matchTagValue = text.substring(0, offset).match(/([\w.]+)=("?)[^"]*$/);
if (matchTagValue) {
return {
type: 'IN_TAG_VALUE',
tagName: matchTagValue[1],
betweenQuotes: !!matchTagValue[2],
otherTags: existingTags,
};
}
// Check if we are editing a tag name
const matchTagName = text.substring(0, offset).match(/[{,]\s*[^"]*$/);
if (matchTagName) {
return {
type: 'IN_TAG_NAME',
otherTags: existingTags,
};
}
// Will happen only if user writes something that isn't really a tag selector
return {
type: 'UNKNOWN',
};
}
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

@ -0,0 +1,106 @@
export const languageConfiguration = {
// the default separators except `@$`
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
brackets: [['{', '}']],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
folding: {},
};
export const language = {
ignoreCase: false,
defaultToken: '',
tokenPostfix: '.traceql',
keywords: [],
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'],
// 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: 'traceql',
extensions: ['.traceql'],
aliases: ['tempo', 'traceql'],
mimetypes: [],
def: {
language,
languageConfiguration,
},
};

View File

@ -0,0 +1,7 @@
import { DataSourceJsonData } from '@grafana/data/src';
import { TempoQuery } from './datasource';
export interface MyDataSourceOptions extends DataSourceJsonData {}
export const defaultQuery: Partial<TempoQuery> = {};