mirror of https://github.com/grafana/grafana
Tempo: Add Tempo search behind feature flag (#37765)
* Add Tempo search behind feature flag * Add query fields for Tempo search * Only show loki search if a logs-to-traces datasource is set up * Refactor tempo search to use separate fields for service name, span name, and tags * Add tests to buildSearchQuery * Move search to separate component and rename type to native search * Improve Tempo tokenizer syntaxpull/38018/head
parent
17306217aa
commit
76b891b001
@ -0,0 +1,177 @@ |
||||
import React, { useState, useEffect, useMemo } from 'react'; |
||||
import { |
||||
InlineFieldRow, |
||||
InlineField, |
||||
Input, |
||||
QueryField, |
||||
Select, |
||||
SlatePrism, |
||||
BracesPlugin, |
||||
TypeaheadInput, |
||||
TypeaheadOutput, |
||||
} from '@grafana/ui'; |
||||
import { tokenizer } from './syntax'; |
||||
import Prism from 'prismjs'; |
||||
import { Node } from 'slate'; |
||||
import { css } from '@emotion/css'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import TempoLanguageProvider from './language_provider'; |
||||
import { TempoDatasource, TempoQuery } from './datasource'; |
||||
|
||||
interface Props { |
||||
datasource: TempoDatasource; |
||||
query: TempoQuery; |
||||
onChange: (value: TempoQuery) => void; |
||||
onBlur?: () => void; |
||||
onRunQuery: () => void; |
||||
} |
||||
|
||||
const PRISM_LANGUAGE = 'tempo'; |
||||
const durationPlaceholder = 'e.g. 1.2s, 100ms, 500us'; |
||||
const plugins = [ |
||||
BracesPlugin(), |
||||
SlatePrism({ |
||||
onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', |
||||
getSyntax: () => PRISM_LANGUAGE, |
||||
}), |
||||
]; |
||||
|
||||
Prism.languages[PRISM_LANGUAGE] = tokenizer; |
||||
|
||||
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => { |
||||
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); |
||||
const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false); |
||||
const [autocomplete, setAutocomplete] = useState<{ |
||||
serviceNameOptions: Array<SelectableValue<string>>; |
||||
spanNameOptions: Array<SelectableValue<string>>; |
||||
}>({ |
||||
serviceNameOptions: [], |
||||
spanNameOptions: [], |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
const fetchAutocomplete = async () => { |
||||
await languageProvider.start(); |
||||
const serviceNameOptions = await languageProvider.getOptions('service.name'); |
||||
const spanNameOptions = await languageProvider.getOptions('name'); |
||||
setHasSyntaxLoaded(true); |
||||
setAutocomplete({ serviceNameOptions, spanNameOptions }); |
||||
}; |
||||
fetchAutocomplete(); |
||||
}, [languageProvider]); |
||||
|
||||
const onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => { |
||||
return await languageProvider.provideCompletionItems(typeahead); |
||||
}; |
||||
|
||||
const cleanText = (text: string) => { |
||||
const splittedText = text.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/g); |
||||
if (splittedText.length > 1) { |
||||
return splittedText[splittedText.length - 1]; |
||||
} |
||||
return text; |
||||
}; |
||||
|
||||
return ( |
||||
<div className={css({ maxWidth: '500px' })}> |
||||
<InlineFieldRow> |
||||
<InlineField label="Service Name" labelWidth={14} grow> |
||||
<Select |
||||
menuShouldPortal |
||||
options={autocomplete.serviceNameOptions} |
||||
value={query.serviceName || ''} |
||||
onChange={(v) => { |
||||
onChange({ |
||||
...query, |
||||
serviceName: v?.value || undefined, |
||||
}); |
||||
}} |
||||
placeholder="Select a service" |
||||
isClearable |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField label="Span Name" labelWidth={14} grow> |
||||
<Select |
||||
menuShouldPortal |
||||
options={autocomplete.spanNameOptions} |
||||
value={query.spanName || ''} |
||||
onChange={(v) => { |
||||
onChange({ |
||||
...query, |
||||
spanName: v?.value || undefined, |
||||
}); |
||||
}} |
||||
placeholder="Select a span" |
||||
isClearable |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in the logfmt format."> |
||||
<QueryField |
||||
additionalPlugins={plugins} |
||||
query={query.search} |
||||
onTypeahead={onTypeahead} |
||||
onBlur={onBlur} |
||||
onChange={(value) => { |
||||
onChange({ |
||||
...query, |
||||
search: value, |
||||
}); |
||||
}} |
||||
cleanText={cleanText} |
||||
onRunQuery={onRunQuery} |
||||
syntaxLoaded={hasSyntaxLoaded} |
||||
portalOrigin="tempo" |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField label="Min Duration" labelWidth={14} grow> |
||||
<Input |
||||
value={query.minDuration || ''} |
||||
placeholder={durationPlaceholder} |
||||
onChange={(v) => |
||||
onChange({ |
||||
...query, |
||||
minDuration: v.currentTarget.value, |
||||
}) |
||||
} |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField label="Max Duration" labelWidth={14} grow> |
||||
<Input |
||||
value={query.maxDuration || ''} |
||||
placeholder={durationPlaceholder} |
||||
onChange={(v) => |
||||
onChange({ |
||||
...query, |
||||
maxDuration: v.currentTarget.value, |
||||
}) |
||||
} |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField label="Limit" labelWidth={14} grow tooltip="Maximum numbers of returned results"> |
||||
<Input |
||||
value={query.limit || ''} |
||||
type="number" |
||||
onChange={(v) => |
||||
onChange({ |
||||
...query, |
||||
limit: v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined, |
||||
}) |
||||
} |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default NativeSearch; |
@ -0,0 +1,103 @@ |
||||
import { HistoryItem, LanguageProvider, SelectableValue } from '@grafana/data'; |
||||
import { CompletionItemGroup, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; |
||||
import { Value } from 'slate'; |
||||
import { TempoDatasource } from './datasource'; |
||||
|
||||
export default class TempoLanguageProvider extends LanguageProvider { |
||||
datasource: TempoDatasource; |
||||
tags?: string[]; |
||||
constructor(datasource: TempoDatasource, initialValues?: any) { |
||||
super(); |
||||
|
||||
this.datasource = datasource; |
||||
Object.assign(this, initialValues); |
||||
} |
||||
|
||||
request = async (url: string, defaultValue: any, params = {}) => { |
||||
try { |
||||
const res = await this.datasource.metadataRequest(url, params); |
||||
return res?.data; |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
|
||||
return defaultValue; |
||||
}; |
||||
|
||||
start = async () => { |
||||
await this.fetchTags(); |
||||
return []; |
||||
}; |
||||
|
||||
async fetchTags() { |
||||
try { |
||||
const response = await this.request('/api/search/tags', []); |
||||
this.tags = response.tagNames; |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
provideCompletionItems = async ( |
||||
{ prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput, |
||||
context: { history: Array<HistoryItem<any>> } = { history: [] } |
||||
): Promise<TypeaheadOutput> => { |
||||
const emptyResult: TypeaheadOutput = { suggestions: [] }; |
||||
|
||||
if (!value) { |
||||
return emptyResult; |
||||
} |
||||
if (text === '=') { |
||||
return this.getTagValueCompletionItems(value); |
||||
} |
||||
return this.getTagsCompletionItems(); |
||||
}; |
||||
|
||||
getTagsCompletionItems = (): TypeaheadOutput => { |
||||
const { tags } = this; |
||||
const suggestions: CompletionItemGroup[] = []; |
||||
|
||||
if (tags?.length) { |
||||
suggestions.push({ |
||||
label: `Tag`, |
||||
items: tags.map((tag) => ({ label: tag })), |
||||
}); |
||||
} |
||||
|
||||
return { suggestions }; |
||||
}; |
||||
|
||||
async getTagValueCompletionItems(value: Value) { |
||||
const tagNames = value.endText.getText().split(' '); |
||||
let tagName = tagNames[0]; |
||||
// Get last item if multiple tags
|
||||
if (tagNames.length > 1) { |
||||
tagName = tagNames[tagNames.length - 1]; |
||||
} |
||||
tagName = tagName.slice(0, -1); |
||||
const response = await this.request(`/api/search/tag/${tagName}/values`, []); |
||||
const suggestions: CompletionItemGroup[] = []; |
||||
|
||||
if (response && response.tagValues) { |
||||
suggestions.push({ |
||||
label: `TagValues`, |
||||
items: response.tagValues.map((tagValue: string) => ({ label: tagValue })), |
||||
}); |
||||
} |
||||
return { suggestions }; |
||||
} |
||||
|
||||
async getOptions(tag: string): Promise<Array<SelectableValue<string>>> { |
||||
const response = await this.request(`/api/search/tag/${tag}/values`, []); |
||||
let options: Array<SelectableValue<string>> = []; |
||||
|
||||
if (response && response.tagValues) { |
||||
options = response.tagValues.map((v: string) => ({ |
||||
value: v, |
||||
label: v, |
||||
})); |
||||
} |
||||
|
||||
return options; |
||||
} |
||||
} |
@ -0,0 +1,19 @@ |
||||
import { tokenizer } from './syntax'; |
||||
import Prism from 'prismjs'; |
||||
|
||||
describe('Loki syntax', () => { |
||||
it('should highlight Loki query correctly', () => { |
||||
expect(Prism.highlight('key=value', tokenizer, 'tempo')).toBe( |
||||
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span>' |
||||
); |
||||
expect(Prism.highlight('root.ip=172.123.0.1', tokenizer, 'tempo')).toBe( |
||||
'<span class="token key attr-name">root.ip</span><span class="token operator">=</span><span class="token value">172.123.0.1</span>' |
||||
); |
||||
expect(Prism.highlight('root.name="http get /config"', tokenizer, 'tempo')).toBe( |
||||
'<span class="token key attr-name">root.name</span><span class="token operator">=</span><span class="token value">"http get /config"</span>' |
||||
); |
||||
expect(Prism.highlight('key=value key2=value2', tokenizer, 'tempo')).toBe( |
||||
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span> <span class="token key attr-name">key2</span><span class="token operator">=</span><span class="token value">value2</span>' |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1,17 @@ |
||||
import { Grammar } from 'prismjs'; |
||||
|
||||
export const tokenizer: Grammar = { |
||||
key: { |
||||
pattern: /[^\s]+(?==)/, |
||||
alias: 'attr-name', |
||||
}, |
||||
operator: /[=]/, |
||||
value: [ |
||||
{ |
||||
pattern: /"(.+)"/, |
||||
}, |
||||
{ |
||||
pattern: /[^\s]+/, |
||||
}, |
||||
], |
||||
}; |
Loading…
Reference in new issue