Trace View: Set span filters as panel options (#98328)

* Set span filters through panel options WIP

* Replace vars, tags editor, service and span name are now selects

* Fix undefined access

* Fix sync between panel options and span filters component

* Refactor tags input and use it in the plugin options

* Fix options to panel communication

* Rename Tags file. Fix tag values loading in dropdown

* Fix clear tags

* useMount instead of useEffect

* Replace HorizontalGroup with Stack

* Update betterer results

---------

Co-authored-by: Joey Tawadrous <joey.tawadrous@grafana.com>
This commit is contained in:
Andre Pereira 2025-01-09 17:47:07 +00:00 committed by GitHub
parent 90035f9786
commit ce1ae404db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 597 additions and 341 deletions

View File

@ -4422,6 +4422,17 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "23"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "24"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "6"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "7"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "8"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],

View File

@ -40,7 +40,7 @@ import { createSpanLinkFactory } from './createSpanLink';
import { useChildrenState } from './useChildrenState';
import { useDetailState } from './useDetailState';
import { useHoverIndentGuide } from './useHoverIndentGuide';
import { useSearch } from './useSearch';
import { SearchProps, useSearch } from './useSearch';
import { useViewRange } from './useViewRange';
const getStyles = (theme: GrafanaTheme2) => ({
@ -66,6 +66,7 @@ type Props = {
createSpanLink?: SpanLinkFunc;
focusedSpanId?: string;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
spanFilters?: SearchProps;
};
export function TraceView(props: Props) {
@ -77,6 +78,7 @@ export function TraceView(props: Props) {
createSpanLink: createSpanLinkFromProps,
focusedSpanId: focusedSpanIdFromProps,
createFocusSpanLink: createFocusSpanLinkFromProps,
spanFilters,
} = props;
const {
@ -95,11 +97,9 @@ export function TraceView(props: Props) {
const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide();
const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange();
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState();
const { search, setSearch, spanFilterMatches } = useSearch(traceProp?.spans);
const { search, setSearch, spanFilterMatches } = useSearch(traceProp?.spans, spanFilters);
const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState('');
const [showSpanFilters, setShowSpanFilters] = useToggle(false);
const [showSpanFilterMatchesOnly, setShowSpanFilterMatchesOnly] = useState(false);
const [showCriticalPathSpansOnly, setShowCriticalPathSpansOnly] = useState(false);
const [headerHeight, setHeaderHeight] = useState(100);
const [traceFlameGraphs, setTraceFlameGraphs] = useState<TraceFlameGraphs>({});
const [redrawListView, setRedrawListView] = useState({});
@ -183,10 +183,6 @@ export function TraceView(props: Props) {
setSearch={setSearch}
showSpanFilters={showSpanFilters}
setShowSpanFilters={setShowSpanFilters}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
showCriticalPathSpansOnly={showCriticalPathSpansOnly}
setShowCriticalPathSpansOnly={setShowCriticalPathSpansOnly}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
spanFilterMatches={spanFilterMatches}
datasourceType={datasourceType}
@ -232,8 +228,8 @@ export function TraceView(props: Props) {
scrollElement={scrollElement}
focusedSpanId={focusedSpanId}
focusedSpanIdForSearch={focusedSpanIdForSearch}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
showCriticalPathSpansOnly={showCriticalPathSpansOnly}
showSpanFilterMatchesOnly={search.matchesOnly}
showCriticalPathSpansOnly={search.criticalPathOnly}
createFocusSpanLink={createFocusSpanLink}
topOfViewRef={topOfViewRef}
headerHeight={headerHeight}

View File

@ -29,9 +29,7 @@ export type TracePageSearchBarProps = {
trace: Trace;
search: SearchProps;
spanFilterMatches: Set<string> | undefined;
showSpanFilterMatchesOnly: boolean;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
showCriticalPathSpansOnly: boolean;
setShowCriticalPathSpansOnly: (showCriticalPath: boolean) => void;
focusedSpanIndexForSearch: number;
setFocusedSpanIndexForSearch: Dispatch<SetStateAction<number>>;
@ -46,9 +44,7 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
trace,
search,
spanFilterMatches,
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
setShowCriticalPathSpansOnly,
focusedSpanIndexForSearch,
setFocusedSpanIndexForSearch,
@ -70,17 +66,9 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
return tag.key;
}) ||
(search.query && search.query !== '') ||
showSpanFilterMatchesOnly
search.matchesOnly
);
}, [
search.serviceName,
search.spanName,
search.from,
search.to,
search.tags,
search.query,
showSpanFilterMatchesOnly,
]);
}, [search.serviceName, search.spanName, search.from, search.to, search.tags, search.query, search.matchesOnly]);
return (
<div className={styles.container}>
@ -99,13 +87,13 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
</Button>
<div className={styles.matchesOnly}>
<Switch
value={showSpanFilterMatchesOnly}
value={search.matchesOnly}
onChange={(value) => setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)}
label="Show matches only switch"
disabled={!spanFilterMatches?.size}
/>
<Button
onClick={() => setShowSpanFilterMatchesOnly(!showSpanFilterMatchesOnly)}
onClick={() => setShowSpanFilterMatchesOnly(!search.matchesOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"
@ -116,12 +104,12 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
</div>
<div className={styles.matchesOnly}>
<Switch
value={showCriticalPathSpansOnly}
value={search.criticalPathOnly}
onChange={(value) => setShowCriticalPathSpansOnly(value.currentTarget.checked ?? false)}
label="Show critical path only switch"
/>
<Button
onClick={() => setShowCriticalPathSpansOnly(!showCriticalPathSpansOnly)}
onClick={() => setShowCriticalPathSpansOnly(!search.criticalPathOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"

View File

@ -13,43 +13,28 @@
// limitations under the License.
import { css } from '@emotion/css';
import { SpanStatusCode } from '@opentelemetry/api';
import { uniq } from 'lodash';
import React, { useState, useEffect, memo, useCallback } from 'react';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import { IntervalInput } from '@grafana/o11y-ds-frontend';
import {
Collapse,
HorizontalGroup,
Icon,
InlineField,
InlineFieldRow,
Select,
Tooltip,
useStyles2,
Input,
} from '@grafana/ui';
import { Collapse, HorizontalGroup, Icon, InlineField, InlineFieldRow, Select, Tooltip, useStyles2 } from '@grafana/ui';
import { defaultFilters, randomId, SearchProps, Tag } from '../../../useSearch';
import { defaultFilters, SearchProps } from '../../../useSearch';
import { getTraceServiceNames, getTraceSpanNames } from '../../../utils/tags';
import SearchBarInput from '../../common/SearchBarInput';
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../../constants/span';
import { Trace } from '../../types';
import NextPrevResult from '../SearchBar/NextPrevResult';
import TracePageSearchBar from '../SearchBar/TracePageSearchBar';
import { SpanFiltersTags } from './SpanFiltersTags';
export type SpanFilterProps = {
trace: Trace;
search: SearchProps;
setSearch: React.Dispatch<React.SetStateAction<SearchProps>>;
showSpanFilters: boolean;
setShowSpanFilters: (isOpen: boolean) => void;
showSpanFilterMatchesOnly: boolean;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
showCriticalPathSpansOnly: boolean;
setShowCriticalPathSpansOnly: (showCriticalPathSpansOnly: boolean) => void;
spanFilterMatches: Set<string> | undefined;
datasourceType: string;
};
@ -61,10 +46,6 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
setSearch,
showSpanFilters,
setShowSpanFilters,
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
setShowCriticalPathSpansOnly,
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
@ -72,9 +53,9 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
const styles = { ...useStyles2(getStyles) };
const [serviceNames, setServiceNames] = useState<Array<SelectableValue<string>>>();
const [spanNames, setSpanNames] = useState<Array<SelectableValue<string>>>();
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
const durationRegex = /^\d+(?:\.\d)?\d*(?:ns|us|µs|ms|s|m|h)$/;
@ -84,13 +65,26 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
setTagKeys(undefined);
setTagValues({});
setSearch(defaultFilters);
setShowSpanFilterMatchesOnly(false);
}, [setSearch, setShowSpanFilterMatchesOnly]);
}, [setSearch]);
useEffect(() => {
clear();
}, [clear, trace]);
const setShowSpanFilterMatchesOnly = useCallback(
(showMatchesOnly: boolean) => {
setSearch({ ...search, matchesOnly: showMatchesOnly });
},
[search, setSearch]
);
const setShowCriticalPathSpansOnly = useCallback(
(showCriticalPathSpansOnly: boolean) => {
setSearch({ ...search, criticalPathOnly: showCriticalPathSpansOnly });
},
[search, setSearch]
);
if (!trace) {
return null;
}
@ -103,181 +97,16 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
const getServiceNames = () => {
if (!serviceNames) {
const serviceNames = trace.spans.map((span) => {
return span.process.serviceName;
});
setServiceNames(uniq(serviceNames).sort().map(toOption));
setServiceNames(getTraceServiceNames(trace).map(toOption));
}
};
const getSpanNames = () => {
if (!spanNames) {
const spanNames = trace.spans.map((span) => {
return span.operationName;
});
setSpanNames(uniq(spanNames).sort().map(toOption));
setSpanNames(getTraceSpanNames(trace).map(toOption));
}
};
const getTagKeys = () => {
if (!tagKeys) {
let keys: string[] = [];
let logKeys: string[] = [];
trace.spans.forEach((span) => {
span.tags.forEach((tag) => {
keys.push(tag.key);
});
span.process.tags.forEach((tag) => {
keys.push(tag.key);
});
if (span.logs !== null) {
span.logs.forEach((log) => {
log.fields.forEach((field) => {
logKeys.push(field.key);
});
});
}
if (span.kind) {
keys.push(KIND);
}
if (span.statusCode !== undefined) {
keys.push(STATUS);
}
if (span.statusMessage) {
keys.push(STATUS_MESSAGE);
}
if (span.instrumentationLibraryName) {
keys.push(LIBRARY_NAME);
}
if (span.instrumentationLibraryVersion) {
keys.push(LIBRARY_VERSION);
}
if (span.traceState) {
keys.push(TRACE_STATE);
}
keys.push(ID);
});
keys = uniq(keys).sort();
logKeys = uniq(logKeys).sort();
setTagKeys([...keys, ...logKeys].map(toOption));
}
};
const getTagValues = async (key: string) => {
const values: string[] = [];
trace.spans.forEach((span) => {
const tagValue = span.tags.find((t) => t.key === key)?.value;
if (tagValue) {
values.push(tagValue.toString());
}
const processTagValue = span.process.tags.find((t) => t.key === key)?.value;
if (processTagValue) {
values.push(processTagValue.toString());
}
if (span.logs !== null) {
span.logs.forEach((log) => {
const logsTagValue = log.fields.find((t) => t.key === key)?.value;
if (logsTagValue) {
values.push(logsTagValue.toString());
}
});
}
switch (key) {
case KIND:
if (span.kind) {
values.push(span.kind);
}
break;
case STATUS:
if (span.statusCode !== undefined) {
values.push(SpanStatusCode[span.statusCode].toLowerCase());
}
break;
case STATUS_MESSAGE:
if (span.statusMessage) {
values.push(span.statusMessage);
}
break;
case LIBRARY_NAME:
if (span.instrumentationLibraryName) {
values.push(span.instrumentationLibraryName);
}
break;
case LIBRARY_VERSION:
if (span.instrumentationLibraryVersion) {
values.push(span.instrumentationLibraryVersion);
}
break;
case TRACE_STATE:
if (span.traceState) {
values.push(span.traceState);
}
break;
case ID:
values.push(span.spanID);
break;
default:
break;
}
});
return uniq(values).sort().map(toOption);
};
const onTagChange = (tag: Tag, v: SelectableValue<string>) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, key: v?.value || '', value: undefined } : x;
}),
});
const loadTagValues = async () => {
if (v?.value) {
setTagValues({
...tagValues,
[tag.id]: await getTagValues(v.value),
});
} else {
// removed value
const updatedValues = { ...tagValues };
if (updatedValues[tag.id]) {
delete updatedValues[tag.id];
}
setTagValues(updatedValues);
}
};
loadTagValues();
};
const addTag = () => {
const tag = {
id: randomId(),
operator: '=',
};
setSearch({ ...search, tags: [...search.tags, tag] });
};
const removeTag = (id: string) => {
let tags = search.tags.filter((tag) => {
return tag.id !== id;
});
if (tags.length === 0) {
tags = [
{
id: randomId(),
operator: '=',
},
];
}
setSearch({ ...search, tags: tags });
};
const collapseLabel = (
<>
<Tooltip
@ -323,18 +152,16 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
isClearable
onChange={(v) => setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })}
onOpenMenu={getServiceNames}
options={serviceNames}
options={serviceNames || (search.serviceName ? [search.serviceName].map(toOption) : [])}
placeholder="All service names"
value={search.serviceName || null}
defaultValue={search.serviceName || null}
/>
</HorizontalGroup>
</InlineField>
<SearchBarInput
onChange={(v) => {
setSpanFiltersSearch({ ...search, query: v });
if (v === '') {
setShowSpanFilterMatchesOnly(false);
}
setSpanFiltersSearch({ ...search, query: v, matchesOnly: v !== '' });
}}
value={search.query || ''}
/>
@ -353,7 +180,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
isClearable
onChange={(v) => setSpanFiltersSearch({ ...search, spanName: v?.value || '' })}
onOpenMenu={getSpanNames}
options={spanNames}
options={spanNames || (search.spanName ? [search.spanName].map(toOption) : [])}
placeholder="All span names"
value={search.spanName || null}
/>
@ -404,93 +231,15 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
</InlineFieldRow>
<InlineFieldRow className={styles.tagsRow}>
<InlineField label="Tags" labelWidth={16} tooltip="Filter by tags, process tags or log fields in your spans.">
<div>
{search.tags.map((tag, i) => (
<div key={i}>
<HorizontalGroup spacing={'xs'} width={'auto'}>
<Select
aria-label="Select tag key"
isClearable
key={tag.key}
onChange={(v) => onTagChange(tag, v)}
onOpenMenu={getTagKeys}
options={tagKeys}
placeholder="Select tag"
value={tag.key || null}
/>
<Select
aria-label="Select tag operator"
onChange={(v) => {
setSpanFiltersSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, operator: v.value! } : x;
}),
});
}}
options={[toOption('='), toOption('!='), toOption('=~'), toOption('!~')]}
value={tag.operator}
/>
<span className={styles.tagValues}>
{(tag.operator === '=' || tag.operator === '!=') && (
<Select
aria-label="Select tag value"
isClearable
key={tag.value}
onChange={(v) => {
setSpanFiltersSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.value || '' } : x;
}),
});
}}
options={tagValues[tag.id] ? tagValues[tag.id] : []}
placeholder="Select value"
value={tag.value}
/>
)}
{(tag.operator === '=~' || tag.operator === '!~') && (
<Input
aria-label="Input tag value"
onChange={(v) => {
setSpanFiltersSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.currentTarget?.value || '' } : x;
}),
});
}}
placeholder="Tag value"
width={18}
value={tag.value || ''}
/>
)}
</span>
{(tag.key || tag.value || search.tags.length > 1) && (
<AccessoryButton
aria-label="Remove tag"
variant="secondary"
icon="times"
onClick={() => removeTag(tag.id)}
tooltip="Remove tag"
/>
)}
{(tag.key || tag.value) && i === search.tags.length - 1 && (
<span className={styles.addTag}>
<AccessoryButton
aria-label="Add tag"
variant="secondary"
icon="plus"
onClick={addTag}
tooltip="Add tag"
/>
</span>
)}
</HorizontalGroup>
</div>
))}
</div>
<SpanFiltersTags
search={search}
setSearch={setSpanFiltersSearch}
trace={trace}
tagKeys={tagKeys}
setTagKeys={setTagKeys}
tagValues={tagValues}
setTagValues={setTagValues}
/>
</InlineField>
</InlineFieldRow>
@ -498,9 +247,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
trace={trace}
search={search}
spanFilterMatches={spanFilterMatches}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
showCriticalPathSpansOnly={showCriticalPathSpansOnly}
setShowCriticalPathSpansOnly={setShowCriticalPathSpansOnly}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
@ -537,18 +284,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
justifyContent: 'space-between',
}),
addTag: css({
marginLeft: theme.spacing(1),
}),
intervalInput: css({
margin: '0 -4px 0 0',
}),
tagsRow: css({
margin: '-4px 0 0 0',
}),
tagValues: css({
maxWidth: '200px',
}),
nextPrevResult: css({
flex: 1,
alignItems: 'center',

View File

@ -0,0 +1,201 @@
import { css } from '@emotion/css';
import React from 'react';
import { useMount } from 'react-use';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import { Input, Select, Stack, useStyles2 } from '@grafana/ui';
import { randomId, SearchProps, Tag } from '../../../useSearch';
import { getTraceTagKeys, getTraceTagValues } from '../../../utils/tags';
import { Trace } from '../../types';
interface Props {
search: SearchProps;
setSearch: (search: SearchProps) => void;
trace: Trace;
tagKeys?: Array<SelectableValue<string>>;
setTagKeys: React.Dispatch<React.SetStateAction<Array<SelectableValue<string>> | undefined>>;
tagValues: Record<string, Array<SelectableValue<string>>>;
setTagValues: React.Dispatch<React.SetStateAction<{ [key: string]: Array<SelectableValue<string>> }>>;
}
export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys, tagValues, setTagValues }: Props) => {
const styles = { ...useStyles2(getStyles) };
const getTagKeys = () => {
if (!tagKeys) {
setTagKeys(getTraceTagKeys(trace).map(toOption));
}
};
const getTagValues = (key: string) => {
return getTraceTagValues(trace, key).map(toOption);
};
useMount(() => {
if (search.tags) {
search.tags.forEach((tag) => {
if (tag.key) {
setTagValues({
...tagValues,
[tag.id]: getTagValues(tag.key),
});
}
});
}
});
const onTagChange = (tag: Tag, v: SelectableValue<string>) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, key: v?.value || '', value: undefined } : x;
}),
});
const loadTagValues = async () => {
if (v?.value) {
setTagValues({
...tagValues,
[tag.id]: getTagValues(v.value),
});
} else {
// removed value
const updatedValues = { ...tagValues };
if (updatedValues[tag.id]) {
delete updatedValues[tag.id];
}
setTagValues(updatedValues);
}
};
loadTagValues();
};
const addTag = () => {
const tag = {
id: randomId(),
operator: '=',
};
setSearch({ ...search, tags: [...search.tags, tag] });
};
const removeTag = (id: string) => {
let tags = search.tags.filter((tag) => {
return tag.id !== id;
});
if (tags.length === 0) {
tags = [
{
id: randomId(),
operator: '=',
},
];
}
setSearch({ ...search, tags: tags });
};
return (
<div>
{search.tags?.map((tag, i) => (
<div key={tag.id}>
<Stack gap={0} width={'auto'} justifyContent={'flex-start'} alignItems={'center'}>
<div>
<Select
aria-label="Select tag key"
isClearable
key={tag.key}
onChange={(v) => onTagChange(tag, v)}
onOpenMenu={getTagKeys}
options={tagKeys || (tag.key ? [tag.key].map(toOption) : [])}
placeholder="Select tag"
value={tag.key || null}
/>
</div>
<div>
<Select
aria-label="Select tag operator"
onChange={(v) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, operator: v.value! } : x;
}),
});
}}
options={[toOption('='), toOption('!='), toOption('=~'), toOption('!~')]}
value={tag.operator}
/>
</div>
<span className={styles.tagValues}>
{(tag.operator === '=' || tag.operator === '!=') && (
<Select
aria-label="Select tag value"
isClearable
key={tag.value}
onChange={(v) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.value || '' } : x;
}),
});
}}
options={tagValues[tag.id] ? tagValues[tag.id] : tag.value ? [tag.value].map(toOption) : []}
placeholder="Select value"
value={tag.value}
/>
)}
{(tag.operator === '=~' || tag.operator === '!~') && (
<Input
aria-label="Input tag value"
onChange={(v) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.currentTarget?.value || '' } : x;
}),
});
}}
placeholder="Tag value"
width={18}
value={tag.value || ''}
/>
)}
</span>
{(tag.key || tag.value || search.tags.length > 1) && (
<AccessoryButton
aria-label="Remove tag"
variant="secondary"
icon="times"
onClick={() => removeTag(tag.id)}
tooltip="Remove tag"
/>
)}
{(tag.key || tag.value) && i === search.tags.length - 1 && (
<span className={styles.addTag}>
<AccessoryButton
aria-label="Add tag"
variant="secondary"
icon="plus"
onClick={addTag}
tooltip="Add tag"
/>
</span>
)}
</Stack>
</div>
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
addTag: css({
marginLeft: theme.spacing(1),
}),
tagValues: css({
maxWidth: '200px',
}),
});

View File

@ -41,10 +41,6 @@ export type TracePageHeaderProps = {
setSearch: React.Dispatch<React.SetStateAction<SearchProps>>;
showSpanFilters: boolean;
setShowSpanFilters: (isOpen: boolean) => void;
showSpanFilterMatchesOnly: boolean;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
showCriticalPathSpansOnly: boolean;
setShowCriticalPathSpansOnly: (showCriticalPathSpansOnly: boolean) => void;
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
spanFilterMatches: Set<string> | undefined;
datasourceType: string;
@ -61,10 +57,6 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
setSearch,
showSpanFilters,
setShowSpanFilters,
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
setShowCriticalPathSpansOnly,
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
@ -171,10 +163,6 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
trace={trace}
showSpanFilters={showSpanFilters}
setShowSpanFilters={setShowSpanFilters}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
showCriticalPathSpansOnly={showCriticalPathSpansOnly}
setShowCriticalPathSpansOnly={setShowCriticalPathSpansOnly}
search={search}
setSearch={setSearch}
spanFilterMatches={spanFilterMatches}

View File

@ -1,6 +1,9 @@
import { useMemo, useState } from 'react';
import { cloneDeep, merge } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { InterpolateFunction } from '@grafana/data';
import { filterSpans, TraceSpan } from './components';
export interface SearchProps {
@ -14,6 +17,8 @@ export interface SearchProps {
toOperator: string;
tags: Tag[];
query?: string;
matchesOnly: boolean;
criticalPathOnly: boolean;
}
export interface Tag {
@ -36,17 +41,66 @@ export const defaultFilters = {
fromOperator: '>',
toOperator: '<',
tags: [defaultTagFilter],
matchesOnly: false,
criticalPathOnly: false,
};
/**
* Controls the state of search input that highlights spans if they match the search string.
* @param spans
*/
export function useSearch(spans?: TraceSpan[]) {
const [search, setSearch] = useState<SearchProps>(defaultFilters);
export function useSearch(spans?: TraceSpan[], initialFilters?: SearchProps) {
const [search, setSearch] = useState<SearchProps>(merge(cloneDeep(defaultFilters), initialFilters ?? {}));
useEffect(() => {
if (initialFilters) {
setSearch(merge(cloneDeep(defaultFilters), initialFilters));
}
}, [initialFilters]);
const spanFilterMatches: Set<string> | undefined = useMemo(() => {
return spans && filterSpans(search, spans);
}, [search, spans]);
return { search, setSearch, spanFilterMatches };
}
export function replaceSearchVariables(replaceVariables: InterpolateFunction, search?: SearchProps) {
if (!search) {
return search;
}
const newSearch = { ...search };
if (newSearch.query) {
newSearch.query = replaceVariables(newSearch.query);
}
if (newSearch.serviceNameOperator) {
newSearch.serviceNameOperator = replaceVariables(newSearch.serviceNameOperator);
}
if (newSearch.serviceName) {
newSearch.serviceName = replaceVariables(newSearch.serviceName);
}
if (newSearch.spanNameOperator) {
newSearch.spanNameOperator = replaceVariables(newSearch.spanNameOperator);
}
if (newSearch.spanName) {
newSearch.spanName = replaceVariables(newSearch.spanName);
}
if (newSearch.from) {
newSearch.from = replaceVariables(newSearch.from);
}
if (newSearch.to) {
newSearch.to = replaceVariables(newSearch.to);
}
if (newSearch.tags) {
newSearch.tags = newSearch.tags.map((tag) => {
return {
...tag,
key: replaceVariables(tag.key ?? ''),
value: replaceVariables(tag.value ?? ''),
};
});
}
return newSearch;
}

View File

@ -0,0 +1,135 @@
import { SpanStatusCode } from '@opentelemetry/api';
import { uniq } from 'lodash';
import { Trace } from '../components';
import {
ID,
KIND,
LIBRARY_NAME,
LIBRARY_VERSION,
STATUS,
STATUS_MESSAGE,
TRACE_STATE,
} from '../components/constants/span';
export const getTraceServiceNames = (trace: Trace) => {
const serviceNames = trace.spans.map((span) => {
return span.process.serviceName;
});
return uniq(serviceNames).sort();
};
export const getTraceSpanNames = (trace: Trace) => {
const spanNames = trace.spans.map((span) => {
return span.operationName;
});
return uniq(spanNames).sort();
};
export const getTraceTagKeys = (trace: Trace) => {
let keys: string[] = [];
let logKeys: string[] = [];
trace.spans.forEach((span) => {
span.tags.forEach((tag) => {
keys.push(tag.key);
});
span.process.tags.forEach((tag) => {
keys.push(tag.key);
});
if (span.logs !== null) {
span.logs.forEach((log) => {
log.fields.forEach((field) => {
logKeys.push(field.key);
});
});
}
if (span.kind) {
keys.push(KIND);
}
if (span.statusCode !== undefined) {
keys.push(STATUS);
}
if (span.statusMessage) {
keys.push(STATUS_MESSAGE);
}
if (span.instrumentationLibraryName) {
keys.push(LIBRARY_NAME);
}
if (span.instrumentationLibraryVersion) {
keys.push(LIBRARY_VERSION);
}
if (span.traceState) {
keys.push(TRACE_STATE);
}
keys.push(ID);
});
keys = uniq(keys).sort();
logKeys = uniq(logKeys).sort();
return [...keys, ...logKeys];
};
export const getTraceTagValues = (trace: Trace, key: string) => {
const values: string[] = [];
trace.spans.forEach((span) => {
const tagValue = span.tags.find((t) => t.key === key)?.value;
if (tagValue) {
values.push(tagValue.toString());
}
const processTagValue = span.process.tags.find((t) => t.key === key)?.value;
if (processTagValue) {
values.push(processTagValue.toString());
}
if (span.logs !== null) {
span.logs.forEach((log) => {
const logsTagValue = log.fields.find((t) => t.key === key)?.value;
if (logsTagValue) {
values.push(logsTagValue.toString());
}
});
}
switch (key) {
case KIND:
if (span.kind) {
values.push(span.kind);
}
break;
case STATUS:
if (span.statusCode !== undefined) {
values.push(SpanStatusCode[span.statusCode].toLowerCase());
}
break;
case STATUS_MESSAGE:
if (span.statusMessage) {
values.push(span.statusMessage);
}
break;
case LIBRARY_NAME:
if (span.instrumentationLibraryName) {
values.push(span.instrumentationLibraryName);
}
break;
case LIBRARY_VERSION:
if (span.instrumentationLibraryVersion) {
values.push(span.instrumentationLibraryVersion);
}
break;
case TRACE_STATE:
if (span.traceState) {
values.push(span.traceState);
}
break;
case ID:
values.push(span.spanID);
break;
default:
break;
}
});
return uniq(values).sort();
};

View File

@ -0,0 +1,37 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { SpanFiltersTags } from '../../../features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags';
import { defaultTagFilter, SearchProps } from '../../../features/explore/TraceView/useSearch';
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
type Props = StandardEditorProps<SearchProps, unknown, SearchProps>;
export const TagsEditor = ({ value, onChange, context }: Props) => {
const trace = useMemo(() => transformDataFrames(context.data[0]), [context.data]);
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
useEffect(() => {
if (!value.tags) {
onChange({ ...value, tags: [defaultTagFilter] });
}
}, [onChange, value]);
if (!trace) {
return null;
}
return (
<SpanFiltersTags
search={value}
setSearch={onChange}
trace={trace}
tagKeys={tagKeys}
setTagKeys={setTagKeys}
tagValues={tagValues}
setTagValues={setTagValues}
/>
);
};

View File

@ -8,6 +8,8 @@ import { TraceView } from 'app/features/explore/TraceView/TraceView';
import { SpanLinkFunc } from 'app/features/explore/TraceView/components';
import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform';
import { replaceSearchVariables, SearchProps } from '../../../features/explore/TraceView/useSearch';
const styles = {
wrapper: css({
height: '100%',
@ -19,9 +21,10 @@ export interface TracesPanelOptions {
createSpanLink?: SpanLinkFunc;
focusedSpanId?: string;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
spanFilters?: SearchProps;
}
export const TracesPanel = ({ data, options }: PanelProps<TracesPanelOptions>) => {
export const TracesPanel = ({ data, options, replaceVariables }: PanelProps<TracesPanelOptions>) => {
const topOfViewRef = createRef<HTMLDivElement>();
const traceProp = useMemo(() => transformDataFrames(data.series[0]), [data.series]);
const dataSource = useAsync(async () => {
@ -48,6 +51,7 @@ export const TracesPanel = ({ data, options }: PanelProps<TracesPanelOptions>) =
createSpanLink={options.createSpanLink}
focusedSpanId={options.focusedSpanId}
createFocusSpanLink={options.createFocusSpanLink}
spanFilters={replaceSearchVariables(replaceVariables, options.spanFilters)}
/>
</div>
);

View File

@ -1,6 +1,107 @@
import { PanelPlugin } from '@grafana/data';
import { PanelPlugin, toOption } from '@grafana/data';
import { getTraceServiceNames, getTraceSpanNames } from '../../../features/explore/TraceView/utils/tags';
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
import { TagsEditor } from './TagsEditor';
import { TracesPanel } from './TracesPanel';
import { TracesSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin(TracesPanel).setSuggestionsSupplier(new TracesSuggestionsSupplier());
export const plugin = new PanelPlugin(TracesPanel)
.setPanelOptions((builder, context) => {
const category = ['Span filters'];
const trace = transformDataFrames(context?.data?.[0]);
// Find
builder
.addTextInput({
path: 'spanFilters.query',
name: 'Find in trace',
category,
})
.addBooleanSwitch({
path: 'spanFilters.matchesOnly',
name: 'Show matches only',
defaultValue: false,
category,
})
.addBooleanSwitch({
path: 'spanFilters.criticalPathOnly',
name: 'Show critical path only',
defaultValue: false,
category,
});
// Service name
builder
.addSelect({
path: 'spanFilters.serviceName',
name: 'Service name',
category,
settings: {
options: trace ? getTraceServiceNames(trace).map(toOption) : [],
allowCustomValue: true,
isClearable: true,
},
})
.addRadio({
path: 'spanFilters.serviceNameOperator',
name: 'Service name operator',
defaultValue: '=',
settings: {
options: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
],
},
category,
});
// Span name
builder
.addSelect({
path: 'spanFilters.spanName',
name: 'Span name',
category,
settings: {
options: trace ? getTraceSpanNames(trace).map(toOption) : [],
allowCustomValue: true,
isClearable: true,
},
})
.addRadio({
path: 'spanFilters.spanNameOperator',
name: 'Span name operator',
defaultValue: '=',
settings: {
options: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
],
},
category,
});
// Duration
builder
.addTextInput({
path: 'spanFilters.from',
name: 'Min duration',
category,
})
.addTextInput({
path: 'spanFilters.to',
name: 'Max duration',
category,
});
builder.addCustomEditor({
id: 'tags',
name: 'Tags',
path: 'spanFilters',
category,
editor: TagsEditor,
defaultValue: undefined,
});
})
.setSuggestionsSupplier(new TracesSuggestionsSupplier());