diff --git a/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts b/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts index 372c2d11032..9358380c0f6 100644 --- a/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts +++ b/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts @@ -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); }); }); }); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8c5bde1e0b1..3164290da20 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 62c4fdcf62b..c3755fa954f 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -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; @@ -31,6 +32,7 @@ export interface TraceSearchProps { to?: string; toOperator: string; tags: TraceSearchTag[]; + adhocFilters?: Array>; query?: string; matchesOnly: boolean; criticalPathOnly: boolean; diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index d9fe8698444..9b9a92a1f77 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -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} - /> - + setFocusedSpanId(focusedSpanId === spanId ? undefined : spanId) diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index 14783bdc41f..af412126d6f 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -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(); }); }); diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index fa1f7360720..15495f961a3 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -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 ( - - - - - +
+ + + + + +
); } + +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', + }, + }), + }; +}; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx index 719d1e7aedc..44468873093 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx @@ -64,8 +64,8 @@ describe('', () => { 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('', () => { 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(); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx index 44453f8dd1b..32463c04bce 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx @@ -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 ( - + ); @@ -205,60 +203,72 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) { const depth = get(maxBy(trace.spans, 'depth'), 'depth', 0) + 1; return ( - <> - {getMatchesMetadata(depth, services)} +
-
prevResult(event, buttonEnabled)} onKeyDown={(event) => prevResultOnKeyDown(event, buttonEnabled)} - role="button" tabIndex={buttonEnabled ? 0 : -1} - > - Prev -
-
+
+ />
- + {getMatchesMetadata(depth, services)} +
); }); 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', }), }; }; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx index 96f1ba73aa7..7065847bf40 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx @@ -26,17 +26,12 @@ describe('', () => { 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 ; @@ -46,16 +41,9 @@ describe('', () => { expect(() => render()).not.toThrow(); }); - it('renders clear filter button', () => { - render(); - 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(); - const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' }); + it('renders show all spans switch', async () => { + render(); + const matchesSwitch = await screen.findByRole('switch', { name: 'Show all spans' }); expect(matchesSwitch).toBeInTheDocument(); }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx index 458c0915e5f..c25ff507dd7 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx @@ -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 | undefined; setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; - setShowCriticalPathSpansOnly: (showCriticalPath: boolean) => void; focusedSpanIndexForSearch: number; setFocusedSpanIndexForSearch: Dispatch>; setFocusedSpanIdForSearch: Dispatch>; 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 ( -
-
- <> -
- -
- setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)} - label={t('explore.trace-page-search-bar.label-show-matches', 'Show matches only switch')} - disabled={!spanFilterMatches?.size} - /> - -
-
- setShowCriticalPathSpansOnly(value.currentTarget.checked ?? false)} - label={t('explore.trace-page-search-bar.label-show-paths', 'Show critical path only switch')} - /> - -
-
-
- -
- -
+
+ + { + setShowSpanFilterMatchesOnly(!search.matchesOnly); + }} + />
); }); 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, }), }; }; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx index 69241d632a9..eba49425afb 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx @@ -52,16 +52,10 @@ describe('SpanFilters', () => { let user: ReturnType; const SpanFiltersWithProps = ({ showFilters = true, matches }: { showFilters?: boolean; matches?: Set }) => { 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(); - 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(); expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument(); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx index d48dabdf1fa..eda085bc412 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx @@ -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} /> diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx index d0a45548490..8c9983d084e 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx @@ -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 ( -
+
diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.test.ts b/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.test.ts new file mode 100644 index 00000000000..bd23f81f9bd --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.test.ts @@ -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; +const mockGetTraceTagValues = getTraceTagValues as jest.MockedFunction; + +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 = { + 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: '', + }); + }); + }); +}); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.ts b/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.ts new file mode 100644 index 00000000000..9241606d857 --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TraceAdHocFiltersController.ts @@ -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): AdHocFilterWithLabels { + return { + ...item, + key: item.key, + operator: item.operator, + value: item.value || '', + }; +} + +/** + * Convert AdHocFilterWithLabels back to AdHocFilterItem for storage. + */ +function toAdHocFilterItem(filter: AdHocFilterWithLabels): SelectableValue { + 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>> { + 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>> { + 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> { + 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): 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 + } +} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TraceFilterPills.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TraceFilterPills.tsx new file mode 100644 index 00000000000..58b17a24e6c --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TraceFilterPills.tsx @@ -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 ( + + +
+ setSearch({ ...search, criticalPathOnly: !search.criticalPathOnly })} + /> +
+
+ +
+ 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' }], + }); + } + }} + /> +
+
+ +
+ + 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() }, + ], + }); + } + }} + /> +
+
+
+ ); +} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx index 7cea33b8642..fb735991a3d 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx @@ -96,6 +96,7 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean } const mockUsePluginComponents = usePluginComponents as jest.MockedFunction; 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', () => { diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index 4d3e15928d3..f0038ff1593 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -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) => { )}
- + {t('explore.trace-page-header.overview', 'Overview')}} + isOpen={isOverviewOpen} + onToggle={setIsOverviewOpen} + className={styles.overviewCollapsableSection} + contentClassName={styles.overviewCollapsableSectionContent} + > + + + +
+ +
+ {controller && } +
+ {trace && ( +
+ + + setSearch({ ...search, matchesOnly: showMatchesOnly }) + } + focusedSpanIndexForSearch={focusedSpanIndexForSearch} + setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch} + setFocusedSpanIdForSearch={setFocusedSpanIdForSearch} + datasourceType={datasourceType} + showSpanFilters={showSpanFilters} + /> +
+ )} +
); }); @@ -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), + }), }; }; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts b/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts new file mode 100644 index 00000000000..8615c446294 --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts @@ -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 . + +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({ + 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; +} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx index 4a27ce2105e..cc47cd4ea87 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx @@ -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, diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx index 4d653f60360..5a3f908087b 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx @@ -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({ diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx index 23113a16c24..58efda7659f 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -101,7 +101,6 @@ type TVirtualizedTraceViewOwnProps = { focusedSpanId?: string; focusedSpanIdForSearch: string; showSpanFilterMatchesOnly: boolean; - showCriticalPathSpansOnly: boolean; createFocusSpanLink: (traceId: string, spanId: string) => LinkModel; topOfViewRef?: RefObject; datasourceType: string; @@ -134,20 +133,17 @@ function generateRowStates( detailStates: Map, findMatchesIDs: Set | 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, findMatchesIDs: Set | 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 LinkModel; topOfViewRef?: RefObject; headerHeight: number; diff --git a/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts b/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts index f3ab9f4a00c..4e3decc0798 100644 --- a/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts +++ b/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts @@ -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); + }); }); diff --git a/public/app/features/explore/TraceView/components/utils/filter-spans.tsx b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx index daafa61c3c1..d03016dab78 100644 --- a/public/app/features/explore/TraceView/components/utils/filter-spans.tsx +++ b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx @@ -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>) => { + // 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); }; diff --git a/public/app/features/explore/TraceView/useSearch.test.ts b/public/app/features/explore/TraceView/useSearch.test.ts index a92611b6c94..3a6e17d4f29 100644 --- a/public/app/features/explore/TraceView/useSearch.test.ts +++ b/public/app/features/explore/TraceView/useSearch.test.ts @@ -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('!~'); + }); + }); }); diff --git a/public/app/features/explore/TraceView/useSearch.ts b/public/app/features/explore/TraceView/useSearch.ts index 326f5627b9f..9deb191a8d3 100644 --- a/public/app/features/explore/TraceView/useSearch.ts +++ b/public/app/features/explore/TraceView/useSearch.ts @@ -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> = []; + + // 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 | 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); } diff --git a/public/app/features/explore/state/constants.ts b/public/app/features/explore/state/constants.ts index 12d75401fd8..1788235297b 100644 --- a/public/app/features/explore/state/constants.ts +++ b/public/app/features/explore/state/constants.ts @@ -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, }; diff --git a/public/app/plugins/panel/traces/FiltersEditor.tsx b/public/app/plugins/panel/traces/FiltersEditor.tsx new file mode 100644 index 00000000000..f757f5990ee --- /dev/null +++ b/public/app/plugins/panel/traces/FiltersEditor.tsx @@ -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; + +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 ; +}; diff --git a/public/app/plugins/panel/traces/TagsEditor.tsx b/public/app/plugins/panel/traces/TagsEditor.tsx deleted file mode 100644 index 8c6bdc2bcf6..00000000000 --- a/public/app/plugins/panel/traces/TagsEditor.tsx +++ /dev/null @@ -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; - -export const TagsEditor = ({ value, onChange, context }: Props) => { - const trace = useMemo(() => transformDataFrames(context.data[0]), [context.data]); - const [tagKeys, setTagKeys] = useState>>(); - const [tagValues, setTagValues] = useState<{ [key: string]: Array> }>({}); - - useEffect(() => { - if (!value.tags) { - onChange({ ...value, tags: [DEFAULT_TAG_FILTERS] }); - } - }, [onChange, value]); - - if (!trace) { - return null; - } - - return ( - - ); -}; diff --git a/public/app/plugins/panel/traces/module.tsx b/public/app/plugins/panel/traces/module.tsx index cb3bca215f0..fdbe35c6c91 100644 --- a/public/app/plugins/panel/traces/module.tsx +++ b/public/app/plugins/panel/traces/module.tsx @@ -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()); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6e89681bd14..c4595edaff8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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 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" }