Trace View: Span filters updated to use combobox filters (#112287)

* Start using adhoc filters component in trace view

* Migrate old span filters to adhoc filters

* Add support for duration filter

* Set placeholder in adhoc filters input

* Moved the span graph to the header, above the filters

* Update next and prev buttons, added filter pills, style fixes

* Fix types

* Use canary scenes version

* Remove copyright

* More duration operators

* Added tests for the controller

* More consistent spacing

* Remove unused container style from SpanGraph component

* Update scenes canary

* Update scenes to 6.42.0

* Fix all spans toggle

* Use InlineSwitch instead of Switch

* Fix critical path

* Fix duration filtering

* Add tooltips to filter pills

* Improve duration filter pill

* Improvements to backgrounds of span row. Fixed size of collapsible overview section

* Fix sticky trace view header

* Removed old span filters from panel options

* Migrate old filters to adhocfilters in panel. Use the adhoc filters component in panel options.

* i18n

* Fix tests

* Fix tests
This commit is contained in:
Andre Pereira
2025-11-14 11:19:11 +00:00
committed by GitHub
parent 92ef1c4942
commit 4355b3ed0d
32 changed files with 1937 additions and 518 deletions
@@ -41,7 +41,7 @@ describe('Trace view', () => {
e2e.components.TraceViewer.spanBar()
.its('length')
.should(($span) => {
expect($span).to.be.equal(50);
expect($span).to.be.at.most(50);
});
});
});
-10
View File
@@ -2772,11 +2772,6 @@
"count": 1
}
},
"public/app/features/explore/TraceView/TraceView.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
}
},
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
@@ -2787,11 +2782,6 @@
"count": 1
}
},
"public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx": {
"react-hooks/rules-of-hooks": {
"count": 1
}
},
"public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
@@ -1,6 +1,7 @@
import { DataQuery } from '@grafana/schema';
import { PreferredVisualisationType } from './data';
import { SelectableValue } from './select';
import { TimeRange } from './time';
type AnyQuery = DataQuery & Record<string, any>;
@@ -31,6 +32,7 @@ export interface TraceSearchProps {
to?: string;
toOperator: string;
tags: TraceSearchTag[];
adhocFilters?: Array<SelectableValue<string>>;
query?: string;
matchesOnly: boolean;
criticalPathOnly: boolean;
@@ -30,7 +30,6 @@ import { useDispatch, useSelector } from 'app/types/store';
import { changePanelState } from '../state/explorePane';
import memoizedTraceCriticalPath from './components/CriticalPath';
import SpanGraph from './components/TracePageHeader/SpanGraph';
import { TracePageHeader } from './components/TracePageHeader/TracePageHeader';
import TraceTimelineViewer from './components/TraceTimelineViewer';
import { TraceFlameGraphs } from './components/TraceTimelineViewer/SpanDetail';
@@ -100,7 +99,10 @@ 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(exploreId, traceProp?.spans, spanFilters);
const criticalPath = useMemo(() => memoizedTraceCriticalPath(traceProp), [traceProp]);
const { search, setSearch, spanFilterMatches } = useSearch(exploreId, traceProp?.spans, spanFilters, criticalPath);
const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState('');
const [showSpanFilters, setShowSpanFilters] = useToggle(false);
const [headerHeight, setHeaderHeight] = useState(100);
@@ -178,8 +180,6 @@ export function TraceView(props: Props) {
? props.scrollElement
: document.getElementsByClassName(props.scrollElementClass ?? '')[0];
const criticalPath = memoizedTraceCriticalPath(traceProp);
return (
<>
{props.dataFrames?.length && traceProp ? (
@@ -199,13 +199,11 @@ export function TraceView(props: Props) {
datasourceUid={datasourceUid}
setHeaderHeight={setHeaderHeight}
app={exploreId ? CoreApp.Explore : CoreApp.Unknown}
/>
<SpanGraph
trace={traceProp}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRange={viewRange}
/>
<TraceTimelineViewer
findMatchesIDs={spanFilterMatches}
trace={traceProp}
@@ -240,7 +238,6 @@ export function TraceView(props: Props) {
focusedSpanId={focusedSpanId}
focusedSpanIdForSearch={focusedSpanIdForSearch}
showSpanFilterMatchesOnly={search.matchesOnly}
showCriticalPathSpansOnly={search.criticalPathOnly}
createFocusSpanLink={createFocusSpanLink}
topOfViewRef={topOfViewRef}
headerHeight={headerHeight}
@@ -314,12 +311,14 @@ function useFocusSpanLink(options: {
// Check if the link is to a different trace or not.
// If it's the same trace, only update panel state with setFocusedSpanId (no navigation).
// If it's a different trace, use splitOpenFn to open a new explore panel
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sameTrace = query?.queryType === 'traceql' && (query as TempoQuery).query === traceId;
return mapInternalLinkToExplore({
link,
internalLink: link.internal!,
scopedVars: {},
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
field: {} as Field,
onClickFn: sameTrace
? () => setFocusedSpanId(focusedSpanId === spanId ? undefined : spanId)
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
@@ -84,68 +84,27 @@ describe('TraceViewContainer', () => {
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
});
it('can select next/prev results', async () => {
it('renders next/prev result buttons', async () => {
renderTraceViewContainer();
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' });
await user.click(spanFiltersButton);
const nextResultButton = screen.getByRole('button', { name: 'Next result button' });
const prevResultButton = screen.getByRole('button', { name: 'Prev result button' });
// Buttons should be disabled when there are no filters applied
expect(nextResultButton).toBeDisabled();
expect(prevResultButton).toBeDisabled();
expect(nextResultButton.getAttribute('tabindex')).toBe('-1');
expect(prevResultButton.getAttribute('tabindex')).toBe('-1');
await user.click(screen.getByLabelText('Select tag key'));
const tagOption = screen.getByText('component');
await waitFor(() => expect(tagOption).toBeInTheDocument());
await user.click(tagOption);
await waitFor(() => {
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
).toContain('rowMatchingFilter');
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className
).toContain('rowMatchingFilter');
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className
).toContain('rowMatchingFilter');
});
expect(nextResultButton.getAttribute('tabindex')).toBe('0');
expect(prevResultButton.getAttribute('tabindex')).toBe('0');
await user.click(nextResultButton);
await waitFor(() => {
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
).toContain('rowFocused');
});
await user.click(nextResultButton);
await waitFor(() => {
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className
).toContain('rowFocused');
});
await user.click(prevResultButton);
await waitFor(() => {
expect(
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
).toContain('rowFocused');
});
});
it('show matches only works as expected', async () => {
it('renders show all spans switch', async () => {
renderTraceViewContainer();
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' });
await user.click(spanFiltersButton);
await user.click(screen.getByLabelText('Select tag key'));
const tagOption = screen.getByText('http.status_code');
await waitFor(() => expect(tagOption).toBeInTheDocument());
await user.click(tagOption);
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
// Find the show all spans switch in the search bar
const matchesSwitch = await screen.findByRole('switch', { name: 'Show all spans' });
expect(matchesSwitch).toBeInTheDocument();
await user.click(matchesSwitch);
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1);
// Switch should be checked (showing all spans) and disabled by default (no filters)
expect(matchesSwitch).toBeChecked();
expect(matchesSwitch).toBeDisabled();
});
});
@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { DataFrame, DataLinksContext, SplitOpen, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { PanelChrome } from '@grafana/ui';
import { PanelChrome, useStyles2 } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types/store';
import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor';
@@ -26,7 +27,7 @@ export function TraceViewContainer(props: Props) {
const datasource = useSelector(
(state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined
);
const styles = useStyles2(getStyles);
const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange);
if (!traceProp) {
@@ -34,18 +35,35 @@ export function TraceViewContainer(props: Props) {
}
return (
<PanelChrome padding="none" title={t('explore.trace-view-container.title-trace', 'Trace')}>
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
<TraceView
exploreId={exploreId}
dataFrames={dataFrames}
splitOpenFn={splitOpenFn}
scrollElement={scrollElement}
traceProp={traceProp}
datasource={datasource}
timeRange={timeRange}
/>
</DataLinksContext.Provider>
</PanelChrome>
<div className={styles.container}>
<PanelChrome padding="none" title={t('explore.trace-view-container.title-trace', 'Trace')}>
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
<TraceView
exploreId={exploreId}
dataFrames={dataFrames}
splitOpenFn={splitOpenFn}
scrollElement={scrollElement}
traceProp={traceProp}
datasource={datasource}
timeRange={timeRange}
/>
</DataLinksContext.Provider>
</PanelChrome>
</div>
);
}
const getStyles = () => {
return {
container: css({
'& > section': {
/*
The PanelChrome component sets the overflow property, which prevents the Trace View header from
being sticky by creating a new scrolling ancestor.
This is a workaround to allow the header to be sticky.
*/
overflow: 'initial',
},
}),
};
};
@@ -64,8 +64,8 @@ describe('<NextPrevResult>', () => {
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect(nextResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
expect(prevResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
expect(nextResButton).toBeDisabled();
expect(prevResButton).toBeDisabled();
expect(screen.getByText('0 matches')).toBeDefined();
});
@@ -80,8 +80,8 @@ describe('<NextPrevResult>', () => {
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect(nextResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
expect(prevResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
expect(nextResButton).not.toBeDisabled();
expect(prevResButton).not.toBeDisabled();
expect(screen.getByText('1 match')).toBeDefined();
});
@@ -20,8 +20,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
import { getButtonStyles } from '@grafana/ui/internal';
import { Button, Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
import { Trace } from '../../types/trace';
@@ -114,14 +113,13 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
};
const buttonEnabled = (spanFilterMatches && spanFilterMatches?.size > 0) ?? false;
const buttonClass = buttonEnabled ? styles.button : cx(styles.button, styles.buttonDisabled);
const getTooltip = useCallback(
(content: PopoverContent) => {
return (
<Tooltip content={content} placement="top">
<span className={styles.tooltip}>
<Icon name="info-circle" size="md" />
<Icon name="info-circle" size="sm" />
</span>
</Tooltip>
);
@@ -205,60 +203,72 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
const depth = get(maxBy(trace.spans, 'depth'), 'depth', 0) + 1;
return (
<>
<span className={styles.matches}>{getMatchesMetadata(depth, services)}</span>
<div className={styles.container}>
<div className={buttonEnabled ? styles.buttons : cx(styles.buttons, styles.buttonsDisabled)}>
<div
<Button
aria-label={t('explore.next-prev-result.aria-label-prev', 'Prev result button')}
className={buttonClass}
variant="secondary"
size="md"
icon="arrow-up"
disabled={!buttonEnabled}
onClick={(event) => prevResult(event, buttonEnabled)}
onKeyDown={(event) => prevResultOnKeyDown(event, buttonEnabled)}
role="button"
tabIndex={buttonEnabled ? 0 : -1}
>
<Trans i18nKey="explore.prev">Prev</Trans>
</div>
<div
/>
<Button
aria-label={t('explore.next-prev-result.aria-label-next', 'Next result button')}
className={buttonClass}
variant="secondary"
size="md"
icon="arrow-down"
disabled={!buttonEnabled}
onClick={(event) => nextResult(event, buttonEnabled)}
onKeyDown={(event) => nextResultOnKeyDown(event, buttonEnabled)}
role="button"
tabIndex={buttonEnabled ? 0 : -1}
>
<Trans i18nKey="explore.next">Next</Trans>
</div>
/>
</div>
</>
<span className={styles.matches}>{getMatchesMetadata(depth, services)}</span>
</div>
);
});
export const getStyles = (theme: GrafanaTheme2, showSpanFilters: boolean) => {
const buttonStyles = getButtonStyles({
theme,
variant: 'secondary',
size: showSpanFilters ? 'md' : 'sm',
iconOnly: false,
fill: 'outline',
});
return {
container: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
buttons: css({
display: 'inline-flex',
gap: '4px',
gap: 1,
}),
buttonsDisabled: css({
cursor: 'not-allowed',
}),
button: buttonStyles.button,
buttonDisabled: css(buttonStyles.disabled, { pointerEvents: 'none' }),
button: {
padding: theme.spacing(0, 1),
},
iconButton: css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}),
matches: css({
marginRight: theme.spacing(2),
textWrap: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.text.primary,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
}),
tooltip: css({
color: '#aaa',
margin: '0 0 0 5px',
marginLeft: theme.spacing(0.5),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}),
};
};
@@ -26,17 +26,12 @@ describe('<TracePageSearchBar>', () => {
trace: trace,
search: DEFAULT_SPAN_FILTERS,
spanFilterMatches: props.matches ? new Set(props.matches) : undefined,
showSpanFilterMatchesOnly: false,
setShowSpanFilterMatchesOnly: jest.fn(),
setFocusedSpanIdForSearch: jest.fn(),
focusedSpanIndexForSearch: -1,
setFocusedSpanIndexForSearch: jest.fn(),
setShowCriticalPathSpansOnly: jest.fn(),
datasourceType: '',
clear: jest.fn(),
totalSpans: 100,
showSpanFilters: true,
showCriticalPathSpansOnly: false,
};
return <TracePageSearchBar {...searchBarProps} />;
@@ -46,16 +41,9 @@ describe('<TracePageSearchBar>', () => {
expect(() => render(<TracePageSearchBarWithProps matches={[]} />)).not.toThrow();
});
it('renders clear filter button', () => {
render(<TracePageSearchBarWithProps matches={[]} />);
const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' });
expect(clearFiltersButton).toBeInTheDocument();
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true);
});
it('renders show span filter matches only switch', async () => {
render(<TracePageSearchBarWithProps matches={[]} />);
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
it('renders show all spans switch', async () => {
render(<TracePageSearchBarWithProps matches={['span1']} />);
const matchesSwitch = await screen.findByRole('switch', { name: 'Show all spans' });
expect(matchesSwitch).toBeInTheDocument();
});
});
@@ -13,15 +13,13 @@
// limitations under the License.
import { css } from '@emotion/css';
import { memo, Dispatch, SetStateAction, useMemo } from 'react';
import { memo, Dispatch, SetStateAction } from 'react';
import { GrafanaTheme2, TraceSearchProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Switch, useStyles2 } from '@grafana/ui';
import { getButtonStyles } from '@grafana/ui/internal';
import { t } from '@grafana/i18n';
import { InlineSwitch, useStyles2 } from '@grafana/ui';
import { Trace } from '../../types/trace';
import { convertTimeFilter } from '../../utils/filter-spans';
import NextPrevResult from './NextPrevResult';
@@ -30,12 +28,10 @@ export type TracePageSearchBarProps = {
search: TraceSearchProps;
spanFilterMatches: Set<string> | undefined;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
setShowCriticalPathSpansOnly: (showCriticalPath: boolean) => void;
focusedSpanIndexForSearch: number;
setFocusedSpanIndexForSearch: Dispatch<SetStateAction<number>>;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
datasourceType: string;
clear: () => void;
showSpanFilters: boolean;
};
@@ -45,127 +41,59 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
search,
spanFilterMatches,
setShowSpanFilterMatchesOnly,
setShowCriticalPathSpansOnly,
focusedSpanIndexForSearch,
setFocusedSpanIndexForSearch,
setFocusedSpanIdForSearch,
datasourceType,
clear,
showSpanFilters,
} = props;
const styles = useStyles2(getStyles);
const clearEnabled = useMemo(() => {
return (
(search.serviceName && search.serviceName !== '') ||
(search.spanName && search.spanName !== '') ||
convertTimeFilter(search.from || '') ||
convertTimeFilter(search.to || '') ||
search.tags.length > 1 ||
search.tags.some((tag) => {
return tag.key;
}) ||
(search.query && search.query !== '') ||
search.matchesOnly
);
}, [search.serviceName, search.spanName, search.from, search.to, search.tags, search.query, search.matchesOnly]);
return (
<div className={styles.container}>
<div className={styles.controls}>
<>
<div>
<Button
variant="destructive"
disabled={!clearEnabled}
type="button"
fill="outline"
aria-label={t('explore.trace-page-search-bar.aria-label-clear-filters', 'Clear filters button')}
onClick={clear}
>
<Trans i18nKey="explore.clear">Clear</Trans>
</Button>
<div className={styles.matchesOnly}>
<Switch
value={search.matchesOnly}
onChange={(value) => setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)}
label={t('explore.trace-page-search-bar.label-show-matches', 'Show matches only switch')}
disabled={!spanFilterMatches?.size}
/>
<Button
onClick={() => setShowSpanFilterMatchesOnly(!search.matchesOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"
disabled={!spanFilterMatches?.size}
>
<Trans i18nKey="explore.show-matches-only">Show matches only</Trans>
</Button>
</div>
<div className={styles.matchesOnly}>
<Switch
value={search.criticalPathOnly}
onChange={(value) => setShowCriticalPathSpansOnly(value.currentTarget.checked ?? false)}
label={t('explore.trace-page-search-bar.label-show-paths', 'Show critical path only switch')}
/>
<Button
onClick={() => setShowCriticalPathSpansOnly(!search.criticalPathOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"
>
<Trans i18nKey="explore.show-critical-path-only">Show critical path only</Trans>
</Button>
</div>
</div>
<div className={styles.nextPrevResult}>
<NextPrevResult
trace={trace}
spanFilterMatches={spanFilterMatches}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
showSpanFilters={showSpanFilters}
/>
</div>
</>
</div>
<div className={styles.controls}>
<NextPrevResult
trace={trace}
spanFilterMatches={spanFilterMatches}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
showSpanFilters={showSpanFilters}
/>
<InlineSwitch
showLabel={true}
value={!search.matchesOnly}
label={t('explore.show-all-spans', 'Show all spans')}
disabled={!spanFilterMatches?.size}
className={styles.switch}
onChange={(e) => {
setShowSpanFilterMatchesOnly(!search.matchesOnly);
}}
/>
</div>
);
});
export const getStyles = (theme: GrafanaTheme2) => {
const buttonStyles = getButtonStyles({ theme, variant: 'secondary', size: 'md', iconOnly: false, fill: 'outline' });
return {
button: css(buttonStyles.button),
buttonDisabled: css(buttonStyles.disabled, { pointerEvents: 'none', cursor: 'not-allowed' }),
container: css({
display: 'inline',
}),
controls: css({
display: 'flex',
justifyContent: 'flex-end',
margin: '5px 0 0 0',
}),
matchesOnly: css({
display: 'inline-flex',
margin: '0 0 0 25px',
verticalAlign: 'middle',
alignItems: 'center',
gap: theme.spacing(1),
}),
switch: css({
flexDirection: 'row-reverse',
gap: theme.spacing(0.5),
label: {
padding: 0,
fontSize: theme.typography.bodySmall.fontSize,
},
}),
clearMatchesButton: css({
color: theme.colors.text.primary,
'&:hover': {
background: 'inherit',
},
}),
nextPrevResult: css({
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
}),
};
};
@@ -52,16 +52,10 @@ describe('SpanFilters', () => {
let user: ReturnType<typeof userEvent.setup>;
const SpanFiltersWithProps = ({ showFilters = true, matches }: { showFilters?: boolean; matches?: Set<string> }) => {
const [search, setSearch] = useState(DEFAULT_SPAN_FILTERS);
const [showSpanFilterMatchesOnly, setShowSpanFilterMatchesOnly] = useState(false);
const [showCriticalPathSpansOnly, setShowCriticalPathSpansOnly] = useState(false);
const props = {
trace: trace,
showSpanFilters: showFilters,
setShowSpanFilters: jest.fn(),
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
setShowCriticalPathSpansOnly,
search,
setSearch,
spanFilterMatches: matches,
@@ -246,37 +240,6 @@ describe('SpanFilters', () => {
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
});
it('should allow resetting filters', async () => {
render(<SpanFiltersWithProps matches={new Set('1ed38015486087ca')} />);
const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' });
expect(clearFiltersButton).toBeInTheDocument();
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true);
const serviceValue = screen.getByLabelText('Select service name');
const spanValue = screen.getByLabelText('Select span name');
const tagKey = screen.getByLabelText('Select tag key');
const tagValue = screen.getByLabelText('Select tag value');
await selectAndCheckValue(user, serviceValue, 'Service0');
await selectAndCheckValue(user, spanValue, 'Span0');
await selectAndCheckValue(user, tagKey, 'TagKey0');
await selectAndCheckValue(user, tagValue, 'TagValue0');
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
expect(matchesSwitch).not.toBeChecked();
await user.click(matchesSwitch);
expect(matchesSwitch).toBeChecked();
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(false);
await user.click(clearFiltersButton);
expect(screen.queryByText('Service0')).not.toBeInTheDocument();
expect(screen.queryByText('Span0')).not.toBeInTheDocument();
expect(screen.queryByText('TagKey0')).not.toBeInTheDocument();
expect(screen.queryByText('TagValue0')).not.toBeInTheDocument();
expect(screen.queryByText('Add tag')).not.toBeInTheDocument();
expect(screen.queryByText('Remove tag')).not.toBeInTheDocument();
expect(matchesSwitch).not.toBeChecked();
});
it('renders buttons when span filters is collapsed', async () => {
render(<SpanFiltersWithProps showFilters={false} />);
expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument();
@@ -89,13 +89,6 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
[search, setSearch]
);
const setShowCriticalPathSpansOnly = useCallback(
(showCriticalPathSpansOnly: boolean) => {
setSearch({ ...search, criticalPathOnly: showCriticalPathSpansOnly });
},
[search, setSearch]
);
if (!trace) {
return null;
}
@@ -276,12 +269,10 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
search={search}
spanFilterMatches={spanFilterMatches}
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
setShowCriticalPathSpansOnly={setShowCriticalPathSpansOnly}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
clear={clear}
showSpanFilters={showSpanFilters}
/>
</Collapse>
@@ -25,9 +25,6 @@ import ViewingLayer from './ViewingLayer';
const getStyles = () => {
return {
container: css({
padding: '0 0.5rem 0.5rem 0.5rem',
}),
canvasContainer: css({
position: 'relative',
}),
@@ -75,7 +72,7 @@ const SpanGraph = memo(
const items = memoizedGetitems(trace);
return (
<div className={styles.container}>
<div>
<TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
<div className={styles.canvasContainer}>
<CanvasSpanGraph valueWidth={trace.duration} items={items} />
@@ -0,0 +1,644 @@
import { SelectableValue, TraceSearchProps } from '@grafana/data';
import { AdHocFilterWithLabels } from '@grafana/scenes';
import { getTraceTagKeys, getTraceTagValues } from '../../utils/tags';
import { Trace, TraceSpan } from '../types/trace';
import { TraceAdHocFiltersController } from './TraceAdHocFiltersController';
// Mock the tag utilities
jest.mock('../../utils/tags', () => ({
getTraceTagKeys: jest.fn(),
getTraceTagValues: jest.fn(),
}));
// Mock i18n
jest.mock('@grafana/i18n', () => ({
t: (key: string, defaultValue: string) => defaultValue,
}));
const mockGetTraceTagKeys = getTraceTagKeys as jest.MockedFunction<typeof getTraceTagKeys>;
const mockGetTraceTagValues = getTraceTagValues as jest.MockedFunction<typeof getTraceTagValues>;
describe('TraceAdHocFiltersController', () => {
let mockTrace: Trace;
let mockSearch: TraceSearchProps;
let mockSetSearch: jest.Mock;
let mockWip: AdHocFilterWithLabels | undefined;
let mockSetWip: jest.Mock;
beforeEach(() => {
// Create a minimal mock trace
mockTrace = {
traceID: 'trace1',
spans: [
{
spanID: 'span1',
operationName: 'operation1',
process: {
serviceName: 'service1',
tags: [],
},
tags: [
{ key: 'http.method', value: 'GET' },
{ key: 'http.status_code', value: 200 },
],
logs: [],
} as unknown as TraceSpan,
],
duration: 1000,
startTime: 0,
endTime: 1000,
processes: {},
traceName: 'test-trace',
services: [],
};
mockSearch = {
serviceNameOperator: '=',
spanNameOperator: '=',
fromOperator: '>=',
toOperator: '<=',
tags: [],
matchesOnly: false,
criticalPathOnly: false,
adhocFilters: [],
};
mockSetSearch = jest.fn();
mockWip = undefined;
mockSetWip = jest.fn();
// Reset mocks
jest.clearAllMocks();
});
describe('constructor', () => {
it('initializes with provided values', () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
expect(controller).toBeDefined();
});
});
describe('useState', () => {
it('returns current state with no filters', () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const state = controller.useState();
expect(state).toEqual({
filters: [],
readOnly: false,
allowCustomValue: true,
supportsMultiValueOperators: false,
wip: undefined,
inputPlaceholder: 'Filter by attribute or text',
});
});
it('returns current state with existing filters', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'service.name', operator: '!=', value: 'test' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const state = controller.useState();
expect(state.filters).toHaveLength(2);
expect(state.filters[0]).toMatchObject({
key: 'http.method',
operator: '=',
value: 'GET',
});
expect(state.filters[1]).toMatchObject({
key: 'service.name',
operator: '!=',
value: 'test',
});
});
it('includes wip filter in state', () => {
mockWip = {
key: 'http.status_code',
operator: '=',
value: '',
};
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const state = controller.useState();
expect(state.wip).toEqual(mockWip);
});
});
describe('getKeys', () => {
it('returns available keys including special keys', async () => {
mockGetTraceTagKeys.mockReturnValue(['http.method', 'http.status_code', 'service.name']);
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const keys = await controller.getKeys(null);
expect(keys).toHaveLength(5); // Text search, duration, + 3 trace keys
expect(keys[0]).toMatchObject({
label: 'Text search',
value: '_textSearch_',
});
expect(keys[1]).toMatchObject({
label: 'duration',
value: 'duration',
});
expect(keys[2]).toMatchObject({
value: 'http.method',
});
});
it('calls getTraceTagKeys with trace', async () => {
mockGetTraceTagKeys.mockReturnValue(['key1', 'key2']);
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
await controller.getKeys(null);
expect(mockGetTraceTagKeys).toHaveBeenCalledWith(mockTrace);
});
});
describe('getValuesFor', () => {
it('returns empty array when no key is provided', async () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const values = await controller.getValuesFor({ key: '', operator: '=', value: '' });
expect(values).toEqual([]);
});
it('returns duration values for duration key', async () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const values = await controller.getValuesFor({ key: 'duration', operator: '=', value: '' });
expect(values).toHaveLength(4);
expect(values[0]).toMatchObject({ label: '1ms', value: '1ms' });
expect(values[1]).toMatchObject({ label: '1s', value: '1s' });
expect(values[2]).toMatchObject({ label: '1m', value: '1m' });
expect(values[3]).toMatchObject({ label: '1h', value: '1h' });
});
it('returns placeholder for text search key', async () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const values = await controller.getValuesFor({ key: '_textSearch_', operator: '=~', value: '' });
expect(values).toHaveLength(1);
expect(values[0]).toMatchObject({
label: 'Type a value',
value: 'customValue',
isDisabled: true,
});
});
it('returns values from trace for regular keys', async () => {
mockGetTraceTagValues.mockReturnValue(['GET', 'POST', 'PUT']);
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const values = await controller.getValuesFor({ key: 'http.method', operator: '=', value: '' });
expect(mockGetTraceTagValues).toHaveBeenCalledWith(mockTrace, 'http.method');
expect(values).toHaveLength(3);
expect(values[0]).toMatchObject({ value: 'GET' });
expect(values[1]).toMatchObject({ value: 'POST' });
expect(values[2]).toMatchObject({ value: 'PUT' });
});
});
describe('getOperators', () => {
it('returns default operators', () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const operators = controller.getOperators();
expect(operators).toHaveLength(4);
expect(operators).toEqual([
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
{ label: '=~', value: '=~' },
{ label: '!~', value: '!~' },
]);
});
it('returns only =~ for text search', () => {
mockWip = { key: '_textSearch_', operator: '=~', value: '' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const operators = controller.getOperators();
expect(operators).toHaveLength(1);
expect(operators[0]).toEqual({ label: '=~', value: '=~' });
});
it('returns comparison operators for duration', () => {
mockWip = { key: 'duration', operator: '>=', value: '' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const operators = controller.getOperators();
expect(operators).toHaveLength(5);
expect(operators).toEqual([
{ label: '=', value: '=' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
]);
});
});
describe('updateFilter', () => {
it('updates wip filter without adding to filters', () => {
mockWip = { key: 'http.method', operator: '=', value: '' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.updateFilter(mockWip, { key: 'http.status_code' });
expect(mockSetWip).toHaveBeenCalledWith({
key: 'http.status_code',
operator: '=',
value: '',
});
expect(mockSetSearch).not.toHaveBeenCalled();
});
it('completes wip filter when value is set', () => {
mockWip = { key: 'http.method', operator: '=', value: '' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.updateFilter(mockWip, { value: 'GET' });
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [
{
key: 'http.method',
operator: '=',
value: 'GET',
},
],
});
expect(mockSetWip).toHaveBeenCalledWith(undefined);
});
it('does not complete wip filter when value is empty string', () => {
mockWip = { key: 'http.method', operator: '=', value: 'GET' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.updateFilter(mockWip, { value: '' });
expect(mockSetWip).toHaveBeenCalledWith({
key: 'http.method',
operator: '=',
value: '',
});
expect(mockSetSearch).not.toHaveBeenCalled();
});
it('updates existing filter', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToUpdate: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.updateFilter(filterToUpdate, { value: 'POST' });
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [
{ key: 'http.method', operator: '=', value: 'POST' },
{ key: 'http.status_code', operator: '=', value: '200' },
],
});
});
it('updates operator of existing filter', () => {
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToUpdate: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.updateFilter(filterToUpdate, { operator: '!=' });
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [{ key: 'http.method', operator: '!=', value: 'GET' }],
});
});
});
describe('updateToMatchAll', () => {
it('updates filter to match all pattern', () => {
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToUpdate: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.updateToMatchAll(filterToUpdate);
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [
{
key: 'http.method',
operator: '=~',
value: '.*',
matchAllFilter: true,
},
],
});
});
});
describe('removeFilter', () => {
it('removes a filter from the list', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToRemove: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.removeFilter(filterToRemove);
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [{ key: 'http.status_code', operator: '=', value: '200' }],
});
});
it('handles removing from empty list', () => {
mockSearch.adhocFilters = [];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToRemove: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.removeFilter(filterToRemove);
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [],
});
});
it('removes only matching filter', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.method', operator: '!=', value: 'POST' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filterToRemove: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.removeFilter(filterToRemove);
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [
{ key: 'http.method', operator: '!=', value: 'POST' },
{ key: 'http.status_code', operator: '=', value: '200' },
],
});
});
});
describe('removeLastFilter', () => {
it('removes the last filter', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.removeLastFilter();
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [{ key: 'http.method', operator: '=', value: 'GET' }],
});
});
it('handles empty filter list', () => {
mockSearch.adhocFilters = [];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.removeLastFilter();
expect(mockSetSearch).not.toHaveBeenCalled();
});
it('removes the only filter', () => {
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.removeLastFilter();
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [],
});
});
});
describe('handleComboboxBackspace', () => {
it('sets forceEdit on previous filter', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const currentFilter: AdHocFilterWithLabels = {
key: 'http.status_code',
operator: '=',
value: '200',
};
controller.handleComboboxBackspace(currentFilter);
expect(mockSetSearch).toHaveBeenCalledWith({
...mockSearch,
adhocFilters: [
{ key: 'http.method', operator: '=', value: 'GET', forceEdit: true },
{ key: 'http.status_code', operator: '=', value: '200', forceEdit: false },
],
});
});
it('does nothing for first filter', () => {
mockSearch.adhocFilters = [
{ key: 'http.method', operator: '=', value: 'GET' },
{ key: 'http.status_code', operator: '=', value: '200' },
];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const currentFilter: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
controller.handleComboboxBackspace(currentFilter);
expect(mockSetSearch).not.toHaveBeenCalled();
});
it('handles filter not in list', () => {
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const nonExistentFilter: AdHocFilterWithLabels = {
key: 'nonexistent',
operator: '=',
value: 'value',
};
controller.handleComboboxBackspace(nonExistentFilter);
expect(mockSetSearch).not.toHaveBeenCalled();
});
});
describe('addWip', () => {
it('creates a new wip filter with default values', () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.addWip();
expect(mockSetWip).toHaveBeenCalledWith({
key: '',
operator: '=',
value: '',
});
});
});
describe('restoreOriginalFilter', () => {
it('does nothing as trace filters do not support origin filters', () => {
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const filter: AdHocFilterWithLabels = {
key: 'http.method',
operator: '=',
value: 'GET',
};
// Should not throw and should not call any setters
expect(() => controller.restoreOriginalFilter(filter)).not.toThrow();
expect(mockSetSearch).not.toHaveBeenCalled();
expect(mockSetWip).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('handles undefined adhocFilters in search', () => {
mockSearch.adhocFilters = undefined;
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const state = controller.useState();
expect(state.filters).toEqual([]);
controller.removeLastFilter();
expect(mockSetSearch).not.toHaveBeenCalled();
});
it('handles filters with additional properties', () => {
const filterWithExtraProps: SelectableValue<string> = {
key: 'http.method',
operator: '=',
value: 'GET',
label: 'HTTP Method',
description: 'The HTTP method',
};
mockSearch.adhocFilters = [filterWithExtraProps];
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
const state = controller.useState();
expect(state.filters[0]).toMatchObject({
key: 'http.method',
operator: '=',
value: 'GET',
label: 'HTTP Method',
description: 'The HTTP method',
});
});
it('handles updateFilter with multiple properties at once', () => {
mockWip = { key: '', operator: '=', value: '' };
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
controller.updateFilter(mockWip, {
key: 'http.method',
operator: '!=',
});
expect(mockSetWip).toHaveBeenCalledWith({
key: 'http.method',
operator: '!=',
value: '',
});
});
});
});
@@ -0,0 +1,250 @@
import { isEqual } from 'lodash';
import { SelectableValue, toOption, TraceSearchProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { AdHocFiltersController, AdHocFiltersControllerState, AdHocFilterWithLabels } from '@grafana/scenes';
import { getTraceTagKeys, getTraceTagValues } from '../../utils/tags';
import { Trace } from '../types/trace';
/**
* Convert AdHocFilterItem to AdHocFilterWithLabels for use with the combobox.
*/
function toAdHocFilterWithLabels(item: SelectableValue<string>): AdHocFilterWithLabels {
return {
...item,
key: item.key,
operator: item.operator,
value: item.value || '',
};
}
/**
* Convert AdHocFilterWithLabels back to AdHocFilterItem for storage.
*/
function toAdHocFilterItem(filter: AdHocFilterWithLabels): SelectableValue<string> {
return {
...filter,
key: filter.key || '',
operator: filter.operator || '=',
value: filter.value || '',
};
}
const TRACE_OPERATORS = [
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
{ label: '=~', value: '=~' },
{ label: '!~', value: '!~' },
];
/**
* Controller for adhoc filters in trace view.
* Provides keys and values from trace spans and syncs state with URL.
*/
export class TraceAdHocFiltersController implements AdHocFiltersController {
private trace: Trace;
private search: TraceSearchProps;
private setSearch: (search: TraceSearchProps) => void;
private wip: AdHocFilterWithLabels | undefined;
private setWip: (wip: AdHocFilterWithLabels | undefined) => void;
constructor(
trace: Trace,
search: TraceSearchProps,
setSearch: (search: TraceSearchProps) => void,
wip: AdHocFilterWithLabels | undefined,
setWip: (wip: AdHocFilterWithLabels | undefined) => void
) {
this.trace = trace;
this.search = search;
this.setSearch = setSearch;
this.wip = wip;
this.setWip = setWip;
}
/**
* React hook to access controller state.
*/
useState(): AdHocFiltersControllerState {
const filters = (this.search.adhocFilters || []).map(toAdHocFilterWithLabels);
return {
filters,
readOnly: false,
allowCustomValue: true,
supportsMultiValueOperators: false,
wip: this.wip,
inputPlaceholder: 'Filter by attribute or text',
};
}
/**
* Get possible keys from trace spans.
*/
async getKeys(currentKey: string | null): Promise<Array<SelectableValue<string>>> {
const keys = getTraceTagKeys(this.trace);
return [
{
label: t('traces.adhocFilters.textSearchLabel', 'Text search'),
value: '_textSearch_',
description: t('traces.adhocFilters.textSearchDescription', 'Search for text in the trace'),
},
{ label: t('traces.adhocFilters.durationLabel', 'duration'), value: 'duration' },
...keys.map(toOption),
];
}
/**
* Get possible values for a specific filter key from trace spans.
*/
async getValuesFor(filter: AdHocFilterWithLabels): Promise<Array<SelectableValue<string>>> {
if (!filter.key) {
return [];
}
if (filter.key === 'duration') {
return [
{ label: t('traces.adhocFilters.duration1ms', '1ms'), value: '1ms' },
{ label: t('traces.adhocFilters.duration1s', '1s'), value: '1s' },
{ label: t('traces.adhocFilters.duration1m', '1m'), value: '1m' },
{ label: t('traces.adhocFilters.duration1h', '1h'), value: '1h' },
];
}
if (filter.key === '_textSearch_') {
return [{ label: t('traces.adhocFilters.customValue', 'Type a value'), value: 'customValue', isDisabled: true }];
}
const values = getTraceTagValues(this.trace, filter.key);
return values.map(toOption);
}
/**
* Get available operators.
*/
getOperators(): Array<SelectableValue<string>> {
if (this.wip?.key === '_textSearch_') {
return [{ label: '=~', value: '=~' }];
}
if (this.wip?.key === 'duration') {
return [
{ label: '=', value: '=' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
];
}
return TRACE_OPERATORS;
}
/**
* Update a filter with partial changes.
*/
updateFilter(filter: AdHocFilterWithLabels, update: Partial<AdHocFilterWithLabels>): void {
const items = this.search.adhocFilters || [];
const filters = items.map(toAdHocFilterWithLabels);
if (filter === this.wip) {
// If we set value we are done with this "work in progress" filter and we can add it
if ('value' in update && update['value'] !== '') {
this.setSearch({
...this.search,
adhocFilters: [...filters, { ...this.wip, ...update }],
});
this.setWip(undefined);
} else {
this.setWip({ ...this.wip, ...update });
}
return;
}
const updatedFilters = filters.map((f) => {
return isEqual(f, filter) ? { ...f, ...update } : f;
});
this.setSearch({
...this.search,
adhocFilters: updatedFilters.map(toAdHocFilterItem),
});
}
/**
* Update a filter to match all values (=~ .*).
*/
updateToMatchAll(filter: AdHocFilterWithLabels): void {
this.updateFilter(filter, {
operator: '=~',
value: '.*',
matchAllFilter: true,
});
}
/**
* Remove a filter.
*/
removeFilter(filter: AdHocFilterWithLabels): void {
const items = this.search.adhocFilters || [];
const filters = items.map(toAdHocFilterWithLabels);
const updatedFilters = filters.filter((f) => !isEqual(f, filter));
this.setSearch({
...this.search,
adhocFilters: updatedFilters.map(toAdHocFilterItem),
});
}
/**
* Remove the last filter in the list.
*/
removeLastFilter(): void {
const filters = this.search.adhocFilters || [];
if (filters.length > 0) {
const updatedFilters = filters.slice(0, -1);
this.setSearch({
...this.search,
adhocFilters: updatedFilters,
});
}
}
/**
* Handle backspace key in combobox.
*/
handleComboboxBackspace(filter: AdHocFilterWithLabels): void {
const items = this.search.adhocFilters || [];
const filters = items.map(toAdHocFilterWithLabels);
const index = filters.findIndex((f) => isEqual(f, filter));
if (index > 0) {
// Focus previous filter by setting forceEdit
const updatedFilters = filters.map((f, i) => {
if (i === index - 1) {
return { ...f, forceEdit: true };
}
return { ...f, forceEdit: false };
});
this.setSearch({
...this.search,
adhocFilters: updatedFilters.map(toAdHocFilterItem),
});
}
}
/**
* Add a new work-in-progress filter.
*/
addWip(): void {
this.setWip(toAdHocFilterWithLabels({ key: '', operator: '=', value: '' }));
}
/**
* Restore an origin filter to its original value.
* Not applicable for trace filters.
*/
restoreOriginalFilter(filter: AdHocFilterWithLabels): void {
// Not applicable for trace filters as they don't have origin filters
}
}
@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import { TraceSearchProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { FilterPill, Stack, Tooltip } from '@grafana/ui';
import { Trace } from '../types/trace';
export interface TraceFilterPillsProps {
trace: Trace;
search: TraceSearchProps;
setSearch: (search: TraceSearchProps) => void;
}
export function TraceFilterPills({ trace, search, setSearch }: TraceFilterPillsProps) {
// Calculate max duration for high latency filter
const sortedDurations = useMemo(() => {
return trace.spans.map((span) => span.duration).sort((a, b) => a - b);
}, [trace.spans]);
const highLatencyThreshold = Math.floor(sortedDurations[Math.floor(sortedDurations.length * 0.9)]);
return (
<Stack gap={1} direction="row">
<Tooltip
content={t(
'explore.trace-page-header.critical-path-tooltip',
'Selects spans in the critical path—the longest sequence of dependent tasks determining the trace minimum duration.'
)}
>
<div>
<FilterPill
selected={search.criticalPathOnly}
label={t('explore.trace-page-header.critical-path', 'Critical path')}
onClick={() => setSearch({ ...search, criticalPathOnly: !search.criticalPathOnly })}
/>
</div>
</Tooltip>
<Tooltip content={t('explore.trace-page-header.errors-tooltip', 'Selects spans where status equals error.')}>
<div>
<FilterPill
selected={
!!search.adhocFilters?.some((f) => f.key === 'status' && f.operator === '=' && f.value === 'error')
}
label={t('explore.trace-page-header.errors', 'Errors')}
onClick={() => {
const hasErrorFilter = search.adhocFilters?.some(
(f) => f.key === 'status' && f.operator === '=' && f.value === 'error'
);
if (hasErrorFilter) {
// Remove error filter
setSearch({
...search,
adhocFilters: search.adhocFilters?.filter(
(f) => !(f.key === 'status' && f.operator === '=' && f.value === 'error')
),
});
} else {
// Add error filter
setSearch({
...search,
adhocFilters: [...(search.adhocFilters || []), { key: 'status', operator: '=', value: 'error' }],
});
}
}}
/>
</div>
</Tooltip>
<Tooltip
content={t(
'explore.trace-page-header.high-latency-tooltip',
'Selects the 10% longest spans in the trace (p90).'
)}
>
<div>
<FilterPill
selected={
!!search.adhocFilters?.some(
(f) =>
f.key === 'duration' && f.operator === '>=' && parseFloat(f.value || '0') === highLatencyThreshold
)
}
label={t('explore.trace-page-header.high-latency', 'High latency')}
onClick={() => {
const hasHighLatencyFilter = search.adhocFilters?.some(
(f) =>
f.key === 'duration' && f.operator === '>=' && parseFloat(f.value || '0') === highLatencyThreshold
);
if (hasHighLatencyFilter) {
// Remove high latency filter
setSearch({
...search,
adhocFilters: search.adhocFilters?.filter((f) => f.key !== 'duration'),
});
} else {
// Add high latency filter (duration >= 70% of max)
setSearch({
...search,
adhocFilters: [
...(search.adhocFilters || []),
{ key: 'duration', operator: '>=', value: highLatencyThreshold.toString() },
],
});
}
}}
/>
</div>
</Tooltip>
</Stack>
);
}
@@ -96,6 +96,7 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
const mockUsePluginComponents = usePluginComponents as jest.MockedFunction<typeof usePluginComponents>;
mockUsePluginComponents.mockReturnValue({ components: [], isLoading: false });
const viewRangeTime: [number, number] = [0, 0];
const defaultProps = {
trace,
timeZone: '',
@@ -103,10 +104,6 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
setSearch: jest.fn(),
showSpanFilters: true,
setShowSpanFilters: jest.fn(),
showSpanFilterMatchesOnly: false,
setShowSpanFilterMatchesOnly: jest.fn(),
showCriticalPathSpansOnly: false,
setShowCriticalPathSpansOnly: jest.fn(),
spanFilterMatches: undefined,
setFocusedSpanIdForSearch: jest.fn(),
datasourceType: 'tempo',
@@ -114,6 +111,9 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
data: new MutableDataFrame(),
datasourceName: 'test-datasource',
datasourceUid: 'test-datasource-uid',
updateNextViewRangeTime: jest.fn(),
updateViewRangeTime: jest.fn(),
viewRange: { time: { current: viewRangeTime } },
};
return {
@@ -133,16 +133,11 @@ describe('TracePageHeader test', () => {
setup();
const header = document.querySelector('header');
const method = getByText(header!, 'POST');
const status = getByText(header!, '200');
const url = getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89');
const duration = getByText(header!, '2.36s');
const timestampElement = getByText(header!, '2023-02-05 08:50:56.289');
expect(method).toBeInTheDocument();
expect(status).toBeInTheDocument();
expect(url).toBeInTheDocument();
expect(duration).toBeInTheDocument();
expect(timestampElement).toBeInTheDocument();
expect(getByText(header!, 'POST')).toBeInTheDocument();
expect(getByText(header!, '200')).toBeInTheDocument();
expect(getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89')).toBeInTheDocument();
expect(screen.getAllByText('2.36s')[0]).toBeInTheDocument();
expect(getByText(header!, '2023-02-05 08:50:56.289')).toBeInTheDocument();
});
describe('Plugin Extensions', () => {
@@ -27,14 +27,17 @@ import {
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime';
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
Badge,
BadgeColor,
Button,
ButtonGroup,
CollapsableSection,
Dropdown,
Icon,
Label,
LinkButton,
Menu,
Tooltip,
@@ -45,11 +48,15 @@ import { useAppNotification } from 'app/core/copy/appNotification';
import { config } from '../../../../../core/config';
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
import { Trace, TraceViewPluginExtensionContext } from '../types/trace';
import { formatDuration } from '../utils/date';
import { SpanFilters } from './SpanFilters/SpanFilters';
import TracePageSearchBar from './SearchBar/TracePageSearchBar';
import SpanGraph from './SpanGraph';
import { TraceFilterPills } from './TraceFilterPills';
import { useTraceAdHocFiltersController } from './useTraceAdHocFiltersController';
export type TracePageHeaderProps = {
trace: Trace | null;
@@ -66,6 +73,9 @@ export type TracePageHeaderProps = {
datasourceName: string;
datasourceUid: string;
setHeaderHeight: (height: number) => void;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRange: ViewRange;
};
export const TracePageHeader = memo((props: TracePageHeaderProps) => {
@@ -77,19 +87,26 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
search,
setSearch,
showSpanFilters,
setShowSpanFilters,
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
datasourceName,
datasourceUid,
setHeaderHeight,
updateNextViewRangeTime,
updateViewRangeTime,
viewRange,
} = props;
const styles = useStyles2(getStyles);
const theme = useTheme2();
const notifyApp = useAppNotification();
const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false);
const [isOverviewOpen, setIsOverviewOpen] = useState(true);
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
// Create controller for adhoc filters
const controller = useTraceAdHocFiltersController(trace, search, setSearch);
useEffect(() => {
setHeaderHeight(document.querySelector('.' + styles.header)?.scrollHeight ?? 0);
@@ -117,6 +134,11 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
extensionPointId: PluginExtensionPoints.TraceViewHeaderActions,
});
// Memoize service count to avoid recomputing on every render
const serviceCount = useMemo(() => {
return new Set(trace?.spans.map((span) => span.process?.serviceName)).size;
}, [trace?.spans]);
if (!trace) {
return null;
}
@@ -127,11 +149,6 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
// Convert date from micro to milli seconds
const formattedTimestamp = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true });
// Memoize service count to avoid recomputing on every render
const serviceCount = useMemo(() => {
return new Set(trace.spans.map((span) => span.process?.serviceName)).size;
}, [trace.spans]);
let statusColor: BadgeColor = 'green';
if (status && status.length > 0) {
if (status[0].value.toString().charAt(0) === '4') {
@@ -351,16 +368,45 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
)}
</div>
<SpanFilters
trace={trace}
showSpanFilters={showSpanFilters}
setShowSpanFilters={setShowSpanFilters}
search={search}
setSearch={setSearch}
spanFilterMatches={spanFilterMatches}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
datasourceType={datasourceType}
/>
<CollapsableSection
label={<span className={styles.overviewLabel}>{t('explore.trace-page-header.overview', 'Overview')}</span>}
isOpen={isOverviewOpen}
onToggle={setIsOverviewOpen}
className={styles.overviewCollapsableSection}
contentClassName={styles.overviewCollapsableSectionContent}
>
<SpanGraph
trace={trace}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
/>
</CollapsableSection>
<div className={styles.filtersContainer}>
<Label>{t('explore.trace-page-header.filters', 'Filters')}</Label>
<div className={styles.adhocFiltersRow}>
{controller && <AdHocFiltersComboboxRenderer controller={controller} />}
</div>
{trace && (
<div className={styles.searchAndPillsRow}>
<TraceFilterPills trace={trace} search={search} setSearch={setSearch} />
<TracePageSearchBar
trace={trace}
search={search}
spanFilterMatches={spanFilterMatches}
setShowSpanFilterMatchesOnly={(showMatchesOnly: boolean) =>
setSearch({ ...search, matchesOnly: showMatchesOnly })
}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
datasourceType={datasourceType}
showSpanFilters={showSpanFilters}
/>
</div>
)}
</div>
</header>
);
});
@@ -425,7 +471,6 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'flex',
alignItems: 'center',
columnGap: theme.spacing(3),
marginBottom: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
flexWrap: 'wrap',
@@ -488,5 +533,38 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'inline-block',
color: theme.colors.text.primary,
}),
overviewLabel: css({
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
display: 'flex',
alignItems: 'center',
}),
overviewCollapsableSection: css({
flexDirection: 'row',
justifyContent: 'flex-start',
gap: theme.spacing(0.5),
}),
overviewCollapsableSectionContent: css({
padding: theme.spacing(0, 1, 2, 1),
}),
filtersContainer: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}),
adhocFiltersRow: css({
display: 'flex',
width: '100%',
}),
searchAndPillsRow: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing(2),
width: '100%',
marginTop: theme.spacing(0.5),
}),
};
};
@@ -0,0 +1,52 @@
// Copyright (c) 2025 Grafana Labs
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useMemo, useState } from 'react';
import { TraceSearchProps } from '@grafana/data';
import { AdHocFiltersController, AdHocFilterWithLabels } from '@grafana/scenes';
import { Trace } from '../types/trace';
import { TraceAdHocFiltersController } from './TraceAdHocFiltersController';
/**
* Hook to create and manage a TraceAdHocFiltersController instance.
* The controller provides keys and values from trace spans and syncs with URL state.
*
* @param trace - The trace to extract keys and values from
* @param search - Current search state including adhoc filters
* @param setSearch - Function to update search state
* @returns Controller instance for use with AdHocFiltersComboboxRenderer
*/
export function useTraceAdHocFiltersController(
trace: Trace | null,
search: TraceSearchProps,
setSearch: (search: TraceSearchProps) => void
): AdHocFiltersController | null {
const [wip, setWip] = useState<AdHocFilterWithLabels | undefined>({
key: '',
operator: '=',
value: '',
});
const controller = useMemo(() => {
if (!trace) {
return null;
}
return new TraceAdHocFiltersController(trace, search, setSearch, wip, setWip);
}, [trace, search, setSearch, wip, setWip]);
return controller;
}
@@ -198,11 +198,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly
}),
rowFocused: css({
label: 'rowFocused',
backgroundColor: autoColor(theme, '#cbe7ff'),
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`,
},
[`& .${nameWrapperClassName}, .${viewClassName}, .${nameWrapperMatchingFilterClassName}`]: {
[`& .${viewClassName}`]: {
backgroundColor: autoColor(theme, '#cbe7ff'),
[theme.transitions.handleMotion('no-preference')]: {
animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`,
@@ -218,15 +217,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly
rowError: css({
label: 'rowError',
backgroundColor: theme.colors.error.transparent,
[`&:hover .${nameWrapperClassName}`]: {
background: theme.colors.error.borderTransparent,
},
[`&:hover .${viewClassName}`]: {
backgroundColor: theme.colors.error.borderTransparent,
outline: `1px solid ${theme.colors.error.borderTransparent}`,
},
[`& .${nameWrapperClassName} > *`]: {
background: theme.colors.error.transparent,
@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { useStyles2, MenuItem, Icon, ContextMenu } from '@grafana/ui';
import { useStyles2, MenuItem, Icon, ContextMenu, useTheme2 } from '@grafana/ui';
import { SpanLinkDef } from '../types/links';
@@ -50,7 +51,8 @@ const renderMenuItems = (
};
export const SpanLinksMenu = ({ links, datasourceType, color }: SpanLinksProps) => {
const styles = useStyles2(() => getStyles(color));
const theme = useTheme2();
const styles = useStyles2(() => getStyles(theme, color));
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
@@ -84,11 +86,10 @@ export const SpanLinksMenu = ({ links, datasourceType, color }: SpanLinksProps)
);
};
const getStyles = (color: string) => ({
const getStyles = (theme: GrafanaTheme2, color: string) => ({
wrapper: css({
border: 'none',
background: `${color}10`,
borderBottom: `1px solid ${color}CF`,
borderBottom: `2px solid ${color}CF`,
paddingInline: '4px',
}),
button: css({
@@ -101,7 +101,6 @@ type TVirtualizedTraceViewOwnProps = {
focusedSpanId?: string;
focusedSpanIdForSearch: string;
showSpanFilterMatchesOnly: boolean;
showCriticalPathSpansOnly: boolean;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
datasourceType: string;
@@ -134,20 +133,17 @@ function generateRowStates(
detailStates: Map<string, DetailState | TNil>,
findMatchesIDs: Set<string> | TNil,
showSpanFilterMatchesOnly: boolean,
showCriticalPathSpansOnly: boolean,
criticalPath: CriticalPathSection[]
): RowState[] {
if (!spans) {
return [];
}
// Apply filtering when matchesOnly is enabled
// Critical path filtering is now integrated into findMatchesIDs
if (showSpanFilterMatchesOnly && findMatchesIDs) {
spans = spans.filter((span) => findMatchesIDs.has(span.spanID));
}
if (showCriticalPathSpansOnly && criticalPath) {
spans = spans.filter((span) => criticalPath.find((section) => section.spanId === span.spanID));
}
let collapseDepth = null;
const rowStates = [];
for (let i = 0; i < spans.length; i++) {
@@ -197,7 +193,6 @@ function generateRowStatesFromTrace(
detailStates: Map<string, DetailState | TNil>,
findMatchesIDs: Set<string> | TNil,
showSpanFilterMatchesOnly: boolean,
showCriticalPathSpansOnly: boolean,
criticalPath: CriticalPathSection[]
): RowState[] {
return trace
@@ -207,7 +202,6 @@ function generateRowStatesFromTrace(
detailStates,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath
)
: [];
@@ -269,22 +263,14 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
}
getRowStates(): RowState[] {
const {
childrenHiddenIDs,
detailStates,
trace,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath,
} = this.props;
const { childrenHiddenIDs, detailStates, trace, findMatchesIDs, showSpanFilterMatchesOnly, criticalPath } =
this.props;
return memoizedGenerateRowStates(
trace,
childrenHiddenIDs,
detailStates,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath
);
}
@@ -103,7 +103,6 @@ export type TProps = {
focusedSpanId?: string;
focusedSpanIdForSearch: string;
showSpanFilterMatchesOnly: boolean;
showCriticalPathSpansOnly: boolean;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
headerHeight: number;
@@ -673,4 +673,27 @@ describe('filterSpans', () => {
)
).toEqual(new Set([spanID0]));
});
it('filters by critical path', () => {
const criticalPath = [
{ spanId: spanID0, section_start: 0, section_end: 1000 },
{ spanId: spanID2, section_start: 1000, section_end: 2000 },
];
// Only critical path spans should be returned
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: true }, spans, criticalPath)).toEqual(
new Set([spanID0, spanID2])
);
// Critical path filtering combined with other filters
expect(
filterSpans({ ...DEFAULT_SPAN_FILTERS, serviceName: 'serviceName0', criticalPathOnly: true }, spans, criticalPath)
).toEqual(new Set([spanID0]));
// When criticalPathOnly is false, critical path should not be applied
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: false }, spans, criticalPath)).toEqual(undefined);
// When no criticalPath is provided, filtering should not be applied
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: true }, spans)).toEqual(undefined);
});
});
@@ -14,20 +14,173 @@
import { SpanStatusCode } from '@opentelemetry/api';
import { TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data';
import { SelectableValue, TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data';
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../constants/span';
import TNil from '../types/TNil';
import { TraceSpan } from '../types/trace';
import { TraceSpan, CriticalPathSection } from '../types/trace';
/**
* Filter spans using adhoc filters.
* Returns filtered spans or undefined if no filters match.
*/
const getAdhocFilterMatches = (spans: TraceSpan[], adhocFilters: Array<SelectableValue<string>>) => {
// Remove empty filters
const validFilters = adhocFilters.filter((filter) => {
return filter.key && filter.key.trim() !== '' && filter.value && filter.value.trim() !== '';
});
if (validFilters.length === 0) {
return undefined;
}
return spans.filter((span: TraceSpan) => {
// All filters must match for the span to be included
return validFilters.every((filter) => {
const key = filter.key || '';
const operator = filter.operator || '=';
const value = filter.value || '';
// Special handling for _textSearch_
if (key === '_textSearch_') {
return matchTextSearch(value, span);
}
// Special handling for serviceName
if (key === 'serviceName') {
return matchField(span.process.serviceName, operator, value);
}
// Special handling for spanName (operationName)
if (key === 'spanName') {
return matchField(span.operationName, operator, value);
}
if (key === 'duration') {
return matchTimeField(span.duration, operator, value);
}
// Handle tag filters (same logic as getTagMatches)
const tagFilter: TraceSearchTag = {
id: '', // Not needed for matching
key,
operator,
value,
};
if (operator === '=' || operator === '!=') {
const matches = checkKeyValConditionForMatch(tagFilter, span);
return operator === '=' ? matches : !matches;
} else if (operator === '=~' || operator === '!~') {
const matches = checkKeyValConditionForRegex(tagFilter, span);
return operator === '=~' ? matches : !matches;
}
return false;
});
});
};
/**
* Match a field value against an operator and expected value.
*/
const matchField = (fieldValue: string, operator: string, expectedValue: string): boolean => {
if (operator === '=') {
return fieldValue === expectedValue;
} else if (operator === '!=') {
return fieldValue !== expectedValue;
} else if (operator === '=~') {
return fieldValue.includes(expectedValue);
} else if (operator === '!~') {
return !fieldValue.includes(expectedValue);
}
return false;
};
/**
* Match a field value against an operator and expected value.
*/
const matchTimeField = (fieldValue: number, operator: string, expectedValue: string): boolean => {
const timeFilter = convertTimeFilter(expectedValue);
if (!timeFilter) {
return false;
}
switch (operator) {
case '>':
return fieldValue > timeFilter;
case '<':
return fieldValue < timeFilter;
case '>=':
return fieldValue >= timeFilter;
case '<=':
return fieldValue <= timeFilter;
case '=':
return fieldValue === timeFilter;
default:
return false;
}
};
/**
* Match text search across all span fields.
*/
const matchTextSearch = (query: string, span: TraceSpan): boolean => {
const queryParts = query
.split(/\s+/)
.filter(Boolean)
.map((w) => w.toLowerCase());
const isTextInQuery = (text: string) => queryParts.some((queryPart) => text.toLowerCase().includes(queryPart));
const isTextInKeyValues = (kvs: TraceKeyValuePair[]) =>
kvs
? kvs.some((kv) => {
return isTextInQuery(kv.key) || isTextInQuery(getStringValue(kv.value));
})
: false;
return (
isTextInQuery(span.operationName) ||
isTextInQuery(span.process.serviceName) ||
isTextInKeyValues(span.tags) ||
(span.kind && isTextInQuery(span.kind)) ||
(span.statusCode !== undefined && isTextInQuery(SpanStatusCode[span.statusCode])) ||
(span.statusMessage && isTextInQuery(span.statusMessage)) ||
(span.instrumentationLibraryName && isTextInQuery(span.instrumentationLibraryName)) ||
(span.instrumentationLibraryVersion && isTextInQuery(span.instrumentationLibraryVersion)) ||
(span.traceState && isTextInQuery(span.traceState)) ||
(span.logs !== null &&
span.logs.some((log) => (log.name && isTextInQuery(log.name)) || isTextInKeyValues(log.fields))) ||
isTextInKeyValues(span.process.tags) ||
queryParts.some((queryPart) => queryPart === span.spanID)
);
};
// filter spans where all filters added need to be true for each individual span that is returned
// i.e. the more filters added -> the more specific that the returned results are
export function filterSpans(searchProps: TraceSearchProps, spans: TraceSpan[] | TNil) {
export function filterSpans(
searchProps: TraceSearchProps,
spans: TraceSpan[] | TNil,
criticalPath?: CriticalPathSection[]
) {
if (!spans) {
return undefined;
}
let filteredSpans = false;
// New adhoc filters approach
if (searchProps.adhocFilters && searchProps.adhocFilters.length > 0) {
const adhocMatches = getAdhocFilterMatches(spans, searchProps.adhocFilters);
if (adhocMatches) {
spans = adhocMatches;
filteredSpans = true;
}
}
// Legacy filters (kept for backward compatibility)
if (searchProps.serviceName) {
spans = getServiceNameMatches(spans, searchProps);
filteredSpans = true;
@@ -54,6 +207,12 @@ export function filterSpans(searchProps: TraceSearchProps, spans: TraceSpan[] |
}
}
// Critical path filtering
if (searchProps.criticalPathOnly && criticalPath) {
spans = getCriticalPathMatches(spans, criticalPath);
filteredSpans = true;
}
return filteredSpans ? new Set(spans.map((span: TraceSpan) => span.spanID)) : undefined;
}
@@ -243,6 +402,12 @@ const getDurationMatches = (spans: TraceSpan[], searchProps: TraceSearchProps) =
return filteredSpans;
};
const getCriticalPathMatches = (spans: TraceSpan[], criticalPath: CriticalPathSection[]) => {
return spans.filter((span: TraceSpan) => {
return criticalPath.some((section) => section.spanId === span.spanID);
});
};
export const convertTimeFilter = (time: string) => {
if (time.includes('ns')) {
return parseFloat(time.split('ns')[0]) / 1000;
@@ -259,5 +424,5 @@ export const convertTimeFilter = (time: string) => {
} else if (time.includes('h')) {
return parseFloat(time.split('h')[0]) * 1000 * 1000 * 60 * 60;
}
return undefined;
return parseFloat(time);
};
@@ -3,7 +3,9 @@ import { act, renderHook } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { DEFAULT_SPAN_FILTERS } from '../state/constants';
import { TraceSearchProps } from '@grafana/data';
import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants';
import { TraceSpan } from './components/types/trace';
import { useSearch } from './useSearch';
@@ -80,4 +82,235 @@ describe('useSearch', () => {
act(() => result.current.setSearch({ ...DEFAULT_SPAN_FILTERS, serviceName: 'service1' }));
expect(result.current.spanFilterMatches).toBe(undefined);
});
describe('migration to adhoc filters', () => {
it('migrates serviceName to adhoc filter', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: 'my-service',
serviceNameOperator: '=',
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that adhoc filter was created
expect(result.current.search.adhocFilters).toHaveLength(1);
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
key: 'serviceName',
operator: '=',
value: 'my-service',
});
});
it('migrates spanName to adhoc filter', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
spanName: 'my-operation',
spanNameOperator: '!=',
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that adhoc filter was created
expect(result.current.search.adhocFilters).toHaveLength(1);
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
key: 'spanName',
operator: '!=',
value: 'my-operation',
});
});
it('migrates query to _textSearch_ adhoc filter', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
query: 'error timeout',
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that adhoc filter was created
expect(result.current.search.adhocFilters).toHaveLength(1);
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
key: '_textSearch_',
operator: '=',
value: 'error timeout',
});
});
it('migrates tags to adhoc filters', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
tags: [
{ id: randomId(), key: 'http.status_code', operator: '=', value: '500' },
{ id: randomId(), key: 'error', operator: '=~', value: 'timeout' },
],
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that adhoc filters were created
expect(result.current.search.adhocFilters).toHaveLength(2);
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
key: 'http.status_code',
operator: '=',
value: '500',
});
expect(result.current.search.adhocFilters?.[1]).toMatchObject({
key: 'error',
operator: '=~',
value: 'timeout',
});
});
it('migrates multiple filter types together', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: 'my-service',
serviceNameOperator: '=',
spanName: 'my-operation',
spanNameOperator: '!=',
query: 'error',
tags: [{ id: randomId(), key: 'http.status_code', operator: '=', value: '500' }],
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that all filters were migrated (serviceName, spanName, query, 1 tag = 4 total)
expect(result.current.search.adhocFilters).toHaveLength(4);
// Verify each filter
const filters = result.current.search.adhocFilters || [];
expect(filters.find((f) => f.key === 'serviceName')).toMatchObject({
key: 'serviceName',
operator: '=',
value: 'my-service',
});
expect(filters.find((f) => f.key === 'spanName')).toMatchObject({
key: 'spanName',
operator: '!=',
value: 'my-operation',
});
expect(filters.find((f) => f.key === '_textSearch_')).toMatchObject({
key: '_textSearch_',
operator: '=',
value: 'error',
});
expect(filters.find((f) => f.key === 'http.status_code')).toMatchObject({
key: 'http.status_code',
operator: '=',
value: '500',
});
});
it('does not migrate if adhoc filters already exist', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: 'my-service',
serviceNameOperator: '=',
adhocFilters: [
{
key: 'existing-key',
operator: '=',
value: 'existing-value',
},
],
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that only existing adhoc filter remains (no migration happened)
expect(result.current.search.adhocFilters).toHaveLength(1);
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
key: 'existing-key',
operator: '=',
value: 'existing-value',
});
});
it('skips empty or whitespace-only filters during migration', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: ' ', // whitespace only
spanName: '', // empty
query: ' ', // whitespace only
tags: [
{ id: randomId(), key: '', operator: '=', value: 'some-value' }, // empty key
{ id: randomId(), key: 'some-key', operator: '=', value: '' }, // empty value
{ id: randomId(), key: ' ', operator: '=', value: ' ' }, // whitespace only
],
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that no adhoc filters were created
expect(result.current.search.adhocFilters).toHaveLength(0);
});
it('applies adhoc filters to span matching', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: 'service1',
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// The serviceName filter should be migrated to adhoc filter and applied
expect(result.current.spanFilterMatches?.size).toBe(1);
expect(result.current.spanFilterMatches?.has('span1')).toBe(true);
expect(result.current.spanFilterMatches?.has('span2')).toBe(false);
});
it('handles different operators during migration', () => {
const store = createMockStore();
const wrapper = createWrapper(store);
const initialFilters: TraceSearchProps = {
...DEFAULT_SPAN_FILTERS,
serviceName: 'my-service',
serviceNameOperator: '!=',
tags: [
{ id: randomId(), key: 'tag1', operator: '=', value: 'value1' },
{ id: randomId(), key: 'tag2', operator: '!=', value: 'value2' },
{ id: randomId(), key: 'tag3', operator: '=~', value: 'pattern' },
{ id: randomId(), key: 'tag4', operator: '!~', value: 'pattern' },
],
};
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
// Check that operators were preserved
expect(result.current.search.adhocFilters).toHaveLength(5);
const filters = result.current.search.adhocFilters || [];
expect(filters.find((f) => f.key === 'serviceName')?.operator).toBe('!=');
expect(filters.find((f) => f.key === 'tag1')?.operator).toBe('=');
expect(filters.find((f) => f.key === 'tag2')?.operator).toBe('!=');
expect(filters.find((f) => f.key === 'tag3')?.operator).toBe('=~');
expect(filters.find((f) => f.key === 'tag4')?.operator).toBe('!~');
});
});
});
@@ -1,23 +1,94 @@
import { cloneDeep, merge } from 'lodash';
import { useEffect, useMemo, useCallback, useState } from 'react';
import { InterpolateFunction, TraceSearchProps } from '@grafana/data';
import { InterpolateFunction, SelectableValue, TraceSearchProps } from '@grafana/data';
import { useDispatch, useSelector } from 'app/types/store';
import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants';
import { changePanelState } from '../state/explorePane';
import { TraceSpan } from './components/types/trace';
import { TraceSpan, CriticalPathSection } from './components/types/trace';
import { filterSpans } from './components/utils/filter-spans';
/**
* Migrate old span filters to new adhoc filters approach.
* Maps serviceName, spanName, tags, and query to adhoc filters.
*/
export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProps {
// If we already have adhoc filters, don't migrate
if (search.adhocFilters && search.adhocFilters.length > 0) {
return search;
}
const adhocFilters: Array<SelectableValue<string>> = [];
// Migrate serviceName
if (search.serviceName && search.serviceName.trim() !== '') {
adhocFilters.push({
key: 'serviceName',
operator: search.serviceNameOperator || '=',
value: search.serviceName,
});
}
// Migrate spanName
if (search.spanName && search.spanName.trim() !== '') {
adhocFilters.push({
key: 'spanName',
operator: search.spanNameOperator || '=',
value: search.spanName,
});
}
// Migrate tags
if (search.tags && search.tags.length > 0) {
search.tags.forEach((tag) => {
// Only migrate tags that have both key and value
if (tag.key && tag.key.trim() !== '' && tag.value && tag.value.trim() !== '') {
adhocFilters.push({
key: tag.key,
operator: tag.operator || '=',
value: tag.value,
});
}
});
}
// Migrate query to _textSearch_
if (search.query && search.query.trim() !== '') {
adhocFilters.push({
key: '_textSearch_',
operator: '=',
value: search.query,
});
}
// Return search with migrated adhoc filters
return {
...search,
adhocFilters,
// Clear old filters after migration
serviceName: undefined,
spanName: undefined,
tags: [{ id: randomId(), operator: '=' }],
query: undefined,
};
}
/**
* Controls the state of search input that highlights spans if they match the search string.
* Uses global state for Explore (when exploreId is provided) or local state for panels (when no exploreId).
* @param exploreId - The explore pane ID (optional, for global state management)
* @param spans - The trace spans to filter
* @param initialFilters - Initial filters to set
* @param criticalPath - The critical path sections (optional)
*/
export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilters?: TraceSearchProps) {
export function useSearch(
exploreId?: string,
spans?: TraceSpan[],
initialFilters?: TraceSearchProps,
criticalPath?: CriticalPathSection[]
) {
const dispatch = useDispatch();
// Global state logic (for Explore)
@@ -31,7 +102,8 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
if (!merged.tags || !Array.isArray(merged.tags)) {
merged.tags = [{ id: randomId(), operator: '=' }];
}
return merged;
// Migrate to adhoc filters
return migrateToAdhocFilters(merged);
});
// Determine which state to use based on exploreId presence
@@ -45,15 +117,39 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
}
// Global state initialization (only when exploreId exists)
// Also handle migration for existing global filters
useEffect(() => {
if (exploreId && !globalFilters) {
const mergedFilters = merge(cloneDeep(DEFAULT_SPAN_FILTERS), initialFilters ?? {});
// Ensure tags is always an array
if (!mergedFilters.tags || !Array.isArray(mergedFilters.tags)) {
mergedFilters.tags = [{ id: randomId(), operator: '=' }];
}
if (exploreId) {
if (!globalFilters) {
// Initialize with migrated filters
let mergedFilters: TraceSearchProps = merge(cloneDeep(DEFAULT_SPAN_FILTERS), initialFilters ?? {});
// Ensure tags is always an array
if (!mergedFilters.tags || !Array.isArray(mergedFilters.tags)) {
mergedFilters.tags = [{ id: randomId(), operator: '=' }];
}
// Ensure adhocFilters is always an array
if (!mergedFilters.adhocFilters) {
mergedFilters.adhocFilters = [];
}
// Migrate to adhoc filters
mergedFilters = migrateToAdhocFilters(mergedFilters);
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: mergedFilters }));
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: mergedFilters }));
} else {
// Check if existing filters need migration
const needsMigration = !globalFilters.adhocFilters || globalFilters.adhocFilters.length === 0;
const hasOldFilters =
globalFilters.serviceName ||
globalFilters.spanName ||
globalFilters.query ||
(globalFilters.tags && globalFilters.tags.some((tag) => tag.key && tag.value));
if (needsMigration && hasOldFilters) {
const migratedFilters = migrateToAdhocFilters(globalFilters);
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: migratedFilters }));
}
}
}
}, [exploreId, initialFilters, globalFilters, dispatch, panelState]);
@@ -61,11 +157,13 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
useEffect(() => {
if (!exploreId && initialFilters) {
setLocalSearch((prev) => {
const merged = merge(cloneDeep(prev), initialFilters);
let merged = merge(cloneDeep(prev), initialFilters);
// Ensure tags is always an array
if (!merged.tags || !Array.isArray(merged.tags)) {
merged.tags = [{ id: randomId(), operator: '=' }];
}
// Migrate to adhoc filters
merged = migrateToAdhocFilters(merged);
return merged;
});
}
@@ -84,8 +182,8 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
);
const spanFilterMatches: Set<string> | undefined = useMemo(() => {
return spans && filterSpans(search, spans);
}, [search, spans]);
return spans && filterSpans(search, spans, criticalPath);
}, [search, spans, criticalPath]);
return { search, setSearch, spanFilterMatches };
}
@@ -102,6 +200,18 @@ export function replaceSearchVariables(replaceVariables: InterpolateFunction, se
newSearch.tags = [{ id: randomId(), operator: '=' }];
}
// Replace variables in adhoc filters
if (newSearch.adhocFilters) {
newSearch.adhocFilters = newSearch.adhocFilters.map((filter) => {
return {
...filter,
key: replaceVariables(filter.key ?? ''),
value: replaceVariables(filter.value ?? ''),
};
});
}
// Legacy filters (kept for backward compatibility)
if (newSearch.query) {
newSearch.query = replaceVariables(newSearch.query);
}
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
import { TraceSearchProps } from '@grafana/data';
import { config } from '@grafana/runtime';
export const DEFAULT_RANGE = {
@@ -14,12 +15,13 @@ export const DEFAULT_TAG_FILTERS = {
operator: '=',
};
export const DEFAULT_SPAN_FILTERS = {
export const DEFAULT_SPAN_FILTERS: TraceSearchProps = {
spanNameOperator: '=',
serviceNameOperator: '=',
fromOperator: '>',
toOperator: '<',
tags: [DEFAULT_TAG_FILTERS],
adhocFilters: [],
matchesOnly: false,
criticalPathOnly: false,
};
@@ -0,0 +1,27 @@
import { useEffect, useMemo } from 'react';
import { StandardEditorProps, TraceSearchProps } from '@grafana/data';
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
import { useTraceAdHocFiltersController } from '../../../features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController';
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
type Props = StandardEditorProps<TraceSearchProps, unknown, TraceSearchProps>;
export const FiltersEditor = ({ value, onChange, context }: Props) => {
const trace = useMemo(() => transformDataFrames(context.data[0]), [context.data]);
useEffect(() => {
if (!value.adhocFilters) {
onChange({ ...value, adhocFilters: [] });
}
}, [onChange, value]);
const controller = useTraceAdHocFiltersController(trace, value, onChange);
if (!trace || !controller) {
return null;
}
return <AdHocFiltersComboboxRenderer controller={controller} />;
};
@@ -1,37 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectableValue, StandardEditorProps, TraceSearchProps } from '@grafana/data';
import { SpanFiltersTags } from '../../../features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags';
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
import { DEFAULT_TAG_FILTERS } from '../../../features/explore/state/constants';
type Props = StandardEditorProps<TraceSearchProps, unknown, TraceSearchProps>;
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: [DEFAULT_TAG_FILTERS] });
}
}, [onChange, value]);
if (!trace) {
return null;
}
return (
<SpanFiltersTags
search={value}
setSearch={onChange}
trace={trace}
tagKeys={tagKeys}
setTagKeys={setTagKeys}
tagValues={tagValues}
setTagValues={setTagValues}
/>
);
};
+23 -84
View File
@@ -1,25 +1,36 @@
import { PanelPlugin, toOption } from '@grafana/data';
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getTraceServiceNames, getTraceSpanNames } from '../../../features/explore/TraceView/utils/tags';
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch';
import { TagsEditor } from './TagsEditor';
import { FiltersEditor } from './FiltersEditor';
import { TracesPanel } from './TracesPanel';
import { TracesSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin(TracesPanel)
.setPanelOptions((builder, context) => {
.setMigrationHandler((panel) => {
// Migrate old span filters to new adhoc filters format
if (panel.options?.spanFilters) {
return {
spanFilters: migrateToAdhocFilters(panel.options.spanFilters),
};
}
return panel.options;
})
.setPanelOptions((builder) => {
const category = [t('traces.category-span-filters', 'Span filters')];
const trace = transformDataFrames(context?.data?.[0]);
builder.addCustomEditor({
id: 'filters',
name: t('traces.name-filters', 'Filters'),
path: 'spanFilters',
category,
editor: FiltersEditor,
defaultValue: undefined,
});
// Find
builder
.addTextInput({
path: 'spanFilters.query',
name: t('traces.name-find-in-trace', 'Find in trace'),
category,
})
.addBooleanSwitch({
path: 'spanFilters.matchesOnly',
name: t('traces.name-show-matches-only', 'Show matches only'),
@@ -28,81 +39,9 @@ export const plugin = new PanelPlugin(TracesPanel)
})
.addBooleanSwitch({
path: 'spanFilters.criticalPathOnly',
name: t('traces.name-critical-path-only', 'Show critical path only'),
name: t('traces.name-critical-path-only', 'Select critical path'),
defaultValue: false,
category,
});
// Service name
builder
.addSelect({
path: 'spanFilters.serviceName',
name: t('traces.name-service-name', 'Service name'),
category,
settings: {
options: trace ? getTraceServiceNames(trace).map(toOption) : [],
allowCustomValue: true,
isClearable: true,
},
})
.addRadio({
path: 'spanFilters.serviceNameOperator',
name: t('traces.name-service-name-operator', 'Service name operator'),
defaultValue: '=',
settings: {
options: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
],
},
category,
});
// Span name
builder
.addSelect({
path: 'spanFilters.spanName',
name: t('traces.name-span-name', 'Span name'),
category,
settings: {
options: trace ? getTraceSpanNames(trace).map(toOption) : [],
allowCustomValue: true,
isClearable: true,
},
})
.addRadio({
path: 'spanFilters.spanNameOperator',
name: t('traces.name-span-name-operator', 'Span name operator'),
defaultValue: '=',
settings: {
options: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
],
},
category,
});
// Duration
builder
.addTextInput({
path: 'spanFilters.from',
name: t('traces.name-min-duration', 'Min duration'),
category,
})
.addTextInput({
path: 'spanFilters.to',
name: t('traces.name-max-duration', 'Max duration'),
category,
});
builder.addCustomEditor({
id: 'tags',
name: t('traces.name-tags', 'Tags'),
path: 'spanFilters',
category,
editor: TagsEditor,
defaultValue: undefined,
});
})
.setSuggestionsSupplier(new TracesSuggestionsSupplier());
+21 -19
View File
@@ -7045,7 +7045,6 @@
"new-panel": "New Panel"
}
},
"clear": "Clear",
"confirm-navigation-modal": {
"cancel": "Cancel",
"new-tab": "Do you want to proceed in the current tab or open a new tab?",
@@ -7269,7 +7268,6 @@
"logs-volumne-panel-list": {
"body-no-logs-volume-available": "No volume information available for the current queries and time range."
},
"next": "Next",
"next-prev-result": {
"aria-label-next": "Next result button",
"aria-label-prev": "Prev result button",
@@ -7290,7 +7288,6 @@
"pane": {
"loading-placeholder": "Loading..."
},
"prev": "Prev",
"queryless-apps-extensions": {
"aria-label-go-queryless": "Go queryless"
},
@@ -7439,8 +7436,7 @@
"query-inspector-button": "Query inspector",
"query-inspector-button-aria-label": "Query inspector"
},
"show-critical-path-only": "Show critical path only",
"show-matches-only": "Show matches only",
"show-all-spans": "Show all spans",
"span-bar": {
"tooltip-critical-path": "A segment on the <1>critical path</1> of the overall trace / request / workflow."
},
@@ -7560,10 +7556,18 @@
},
"trace-page-header": {
"aria-label-share-dropdown": "Open share trace options menu",
"critical-path": "Critical path",
"critical-path-tooltip": "Selects spans in the critical path—the longest sequence of dependent tasks determining the trace minimum duration.",
"duration": "Duration",
"errors": "Errors",
"errors-tooltip": "Selects spans where status equals error.",
"export-started": "Export started",
"filters": "Filters",
"give-feedback": "Feedback",
"high-latency": "High latency",
"high-latency-tooltip": "Selects the 10% longest spans in the trace (p90).",
"link-copied": "Link copied to clipboard",
"overview": "Overview",
"path": "Path",
"route": "Route",
"services": "Services",
@@ -7578,11 +7582,6 @@
"trace-id": "Trace ID",
"url": "URL"
},
"trace-page-search-bar": {
"aria-label-clear-filters": "Clear filters button",
"label-show-matches": "Show matches only switch",
"label-show-paths": "Show critical path only switch"
},
"trace-view": {
"aria-label-copy": "Copy to clipboard",
"no-data": "No data",
@@ -13396,17 +13395,20 @@
}
},
"traces": {
"adhocFilters": {
"customValue": "Type a value",
"duration1h": "1h",
"duration1m": "1m",
"duration1ms": "1ms",
"duration1s": "1s",
"durationLabel": "duration",
"textSearchDescription": "Search for text in the trace",
"textSearchLabel": "Text search"
},
"category-span-filters": "Span filters",
"name-critical-path-only": "Show critical path only",
"name-find-in-trace": "Find in trace",
"name-max-duration": "Max duration",
"name-min-duration": "Min duration",
"name-service-name": "Service name",
"name-service-name-operator": "Service name operator",
"name-critical-path-only": "Select critical path",
"name-filters": "Filters",
"name-show-matches-only": "Show matches only",
"name-span-name": "Span name",
"name-span-name-operator": "Span name operator",
"name-tags": "Tags",
"traces-panel": {
"no-data-found-in-response": "No data found in response"
}