Traces: Span filtering (#65725)

* Filters

* Service/span/duration filters

* Renames for focused span and matches

* Tag filters and new component

* Tag filtering

* Multiple tags and enable next/prev appropriately

* Enum, renames, fixes

* Clean up unecessary props

* setFocusedSearchMatch

* Faster options

* Perf enhancements and cleanup

* General improvements to tags etc

* Updates to filtering

* Add datasourceType in next/prev

* Integrate TracePageSearchBar with NewTracePageSearchBar

* Design tweaks

* Update sticky elem and header design

* Fix tests

* Self review

* Enhancements

* More enhancements

* Update tests

* tests

* More tests

* Add span filters to docs

* Update image link

* Update docs

* Update buttonEnabled and text

* PR review

* Update sticky header

* Doc updates

* Set values for service/span name

* Buffer and dash update
This commit is contained in:
Joey 2023-04-17 08:30:27 +01:00 committed by GitHub
parent 9f0d44d176
commit 9391700d84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1595 additions and 141 deletions

View File

@ -293,6 +293,22 @@ If the file has multiple traces, Grafana visualizes its first trace.
}
```
## Span Filters
> **Note:** This feature is behind the `newTraceView` [feature toggle]({{< relref "../../setup-grafana/configure-grafana#feature_toggles" >}}).
> If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature.
![Screenshot of span filtering](/media/docs/tempo/screenshot-grafana-tempo-span-filters.png)
Using span filters, you can filter your spans in the trace timeline viewer. The more filters you add, the more specific are the filtered spans.
You can add one or more of the following filters:
- Service name
- Span name
- Duration
- Tags (which include tags, process tags, and log fields)
## Link to a trace ID from logs
You can link to Jaeger traces from logs in Loki, Elasticsearch, Splunk, and other logs data sources by configuring an internal link.

View File

@ -357,6 +357,22 @@ To open a query in Prometheus with the span name of that row automatically set i
To open a query in Tempo with the span name of that row automatically set in the query, click a row in the **links** column.
## Span Filters
> **Note:** This feature is behind the `newTraceView` [feature toggle]({{< relref "../../setup-grafana/configure-grafana#feature_toggles" >}}).
> If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature.
![Screenshot of span filtering](/media/docs/tempo/screenshot-grafana-tempo-span-filters.png)
Using span filters, you can filter your spans in the trace timeline viewer. The more filters you add, the more specific are the filtered spans.
You can add one or more of the following filters:
- Service name
- Span name
- Duration
- Tags (which include tags, process tags, and log fields)
## Link to a trace ID from logs
You can link to Tempo traces from logs in Loki, Elasticsearch, Splunk, and other logs data sources by configuring an internal link.

View File

@ -262,6 +262,22 @@ If the file has multiple traces, Grafana visualizes its first trace.
]
```
## Span Filters
> **Note:** This feature is behind the `newTraceView` [feature toggle]({{< relref "../../setup-grafana/configure-grafana#feature_toggles" >}}).
> If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature.
![Screenshot of span filtering](/media/docs/tempo/screenshot-grafana-tempo-span-filters.png)
Using span filters, you can filter your spans in the trace timeline viewer. The more filters you add, the more specific are the filtered spans.
You can add one or more of the following filters:
- Service name
- Span name
- Duration
- Tags (which include tags, process tags, and log fields)
## Link to a trace ID from logs
You can link to Zipkin traces from logs in Loki, Elasticsearch, Splunk, and other logs data sources by configuring an internal link.

View File

@ -40,6 +40,22 @@ This section explains the elements of the Trace View.
Shows condensed view or the trace timeline. Drag your mouse over the minimap to zoom into smaller time range. Zooming will also update the main timeline, so it is easy to see shorter spans. Hovering over the minimap, when zoomed, will show Reset Selection button which resets the zoom.
### Span Filters
> **Note:** This feature is behind the `newTraceView` [feature toggle]({{< relref "../../setup-grafana/configure-grafana#feature_toggles" >}}).
> If you use Grafana Cloud, open a [support ticket in the Cloud Portal](/profile/org#support) to access this feature.
![Screenshot of span filtering](/media/docs/tempo/screenshot-grafana-tempo-span-filters.png)
Using span filters, you can filter your spans in the trace timeline viewer. The more filters you add, the more specific are the filtered spans.
You can add one or more of the following filters:
- Service name
- Span name
- Duration
- Tags (which include tags, process tags, and log fields)
### Timeline
{{< figure src="/static/img/docs/v70/explore-trace-view-timeline.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the trace view timeline" >}}

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import React, { RefObject, useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import {
DataFrame,
@ -34,11 +35,13 @@ import {
TraceTimelineViewer,
TTraceTimeline,
} from './components';
import SpanGraph from './components/TracePageHeader/SpanGraph';
import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView';
import { createSpanLinkFactory } from './createSpanLink';
import { useChildrenState } from './useChildrenState';
import { useDetailState } from './useDetailState';
import { useHoverIndentGuide } from './useHoverIndentGuide';
import { useSearchNewTraceView } from './useSearch';
import { useViewRange } from './useViewRange';
const getStyles = (theme: GrafanaTheme2) => ({
@ -61,6 +64,7 @@ type Props = {
splitOpenFn?: SplitOpen;
exploreId?: ExploreId;
scrollElement?: Element;
scrollElementClass?: string;
traceProp: Trace;
spanFindMatches?: Set<string>;
search: string;
@ -90,6 +94,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 { newTraceViewSearch, setNewTraceViewSearch, spanFilterMatches } = useSearchNewTraceView(traceProp?.spans);
const [newTraceViewFocusedSpanIdForSearch, setNewTraceViewFocusedSpanIdForSearch] = useState('');
const [showSpanFilters, setShowSpanFilters] = useToggle(false);
const [headerHeight, setHeaderHeight] = useState(0);
const styles = useStyles2(getStyles);
@ -136,19 +144,36 @@ export function TraceView(props: Props) {
);
const timeZone = useSelector((state) => getTimeZone(state.user));
const datasourceType = datasource ? datasource?.type : 'unknown';
const scrollElement = props.scrollElement
? props.scrollElement
: document.getElementsByClassName(props.scrollElementClass ?? '')[0];
return (
<>
{props.dataFrames?.length && traceProp ? (
<>
{config.featureToggles.newTraceView ? (
<NewTracePageHeader
trace={traceProp}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRange={viewRange}
timeZone={timeZone}
/>
<>
<NewTracePageHeader
trace={traceProp}
timeZone={timeZone}
search={newTraceViewSearch}
setSearch={setNewTraceViewSearch}
showSpanFilters={showSpanFilters}
setShowSpanFilters={setShowSpanFilters}
focusedSpanIdForSearch={newTraceViewFocusedSpanIdForSearch}
setFocusedSpanIdForSearch={setNewTraceViewFocusedSpanIdForSearch}
spanFilterMatches={spanFilterMatches}
datasourceType={datasourceType}
setHeaderHeight={setHeaderHeight}
/>
<SpanGraph
trace={traceProp}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
/>
</>
) : (
<TracePageHeader
trace={traceProp}
@ -161,7 +186,7 @@ export function TraceView(props: Props) {
<TraceTimelineViewer
registerAccessors={noop}
scrollToFirstVisibleSpan={noop}
findMatchesIDs={spanFindMatches}
findMatchesIDs={config.featureToggles.newTraceView ? spanFilterMatches : spanFindMatches}
trace={traceProp}
datasourceType={datasourceType}
spanBarOptions={spanBarOptions?.spanBar}
@ -192,12 +217,15 @@ export function TraceView(props: Props) {
linksGetter={() => []}
uiFind={props.search}
createSpanLink={createSpanLink}
scrollElement={props.scrollElement}
scrollElement={scrollElement}
focusedSpanId={focusedSpanId}
focusedSpanIdForSearch={props.focusedSpanIdForSearch!}
focusedSpanIdForSearch={
config.featureToggles.newTraceView ? newTraceViewFocusedSpanIdForSearch : props.focusedSpanIdForSearch!
}
createFocusSpanLink={createFocusSpanLink}
topOfViewRef={topOfViewRef}
topOfViewRefType={topOfViewRefType}
headerHeight={headerHeight}
/>
</>
) : (

View File

@ -12,23 +12,29 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { getAllByText, getByText, render } from '@testing-library/react';
import { getByText, render } from '@testing-library/react';
import React from 'react';
import config from 'app/core/config';
import { defaultFilters } from '../../useSearch';
import { NewTracePageHeader } from './NewTracePageHeader';
import { TracePageHeaderEmbedProps } from './TracePageHeader';
import { trace } from './TracePageHeader.test';
const setup = (propOverrides?: TracePageHeaderEmbedProps) => {
const setup = () => {
const defaultProps = {
trace,
timeZone: '',
viewRange: { time: { current: [10, 20] as [number, number] } },
updateNextViewRangeTime: () => {},
updateViewRangeTime: () => {},
...propOverrides,
search: defaultFilters,
setSearch: jest.fn(),
showSpanFilters: true,
setShowSpanFilters: jest.fn(),
spanFilterMatches: undefined,
focusedSpanIdForSearch: '',
setFocusedSpanIdForSearch: jest.fn(),
datasourceType: 'tempo',
setHeaderHeight: jest.fn(),
};
return render(<NewTracePageHeader {...defaultProps} />);
@ -43,13 +49,13 @@ describe('NewTracePageHeader test', () => {
const method = getByText(header!, 'POST');
const status = getByText(header!, '200');
const url = getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89');
const duration = getAllByText(header!, '2.36s');
const duration = getByText(header!, '2.36s');
const timestampPart1 = getByText(header!, '2023-02-05 08:50');
const timestampPart2 = getByText(header!, ':56.289');
expect(method).toBeInTheDocument();
expect(status).toBeInTheDocument();
expect(url).toBeInTheDocument();
expect(duration.length).toBe(2);
expect(duration).toBeInTheDocument();
expect(timestampPart1).toBeInTheDocument();
expect(timestampPart2).toBeInTheDocument();
});

View File

@ -14,75 +14,59 @@
import { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import React, { memo, useEffect, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { Badge, BadgeColor, Tooltip, useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { SearchProps } from '../../useSearch';
import ExternalLinks from '../common/ExternalLinks';
import TraceName from '../common/TraceName';
import { getTraceLinks } from '../model/link-patterns';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
import { Trace } from '../types';
import { formatDuration } from '../utils/date';
import TracePageActions from './Actions/TracePageActions';
import SpanGraph from './SpanGraph';
import { TracePageHeaderEmbedProps, timestamp, getStyles } from './TracePageHeader';
import { SpanFilters } from './SpanFilters/SpanFilters';
import { timestamp, getStyles } from './TracePageHeader';
const getNewStyles = (theme: GrafanaTheme2) => {
return {
titleRow: css`
label: TracePageHeaderTitleRow;
align-items: center;
display: flex;
padding: 0 0.5em 0 0.5em;
`,
title: css`
label: TracePageHeaderTitle;
color: inherit;
flex: 1;
font-size: 1.7em;
line-height: 1em;
`,
subtitle: css`
flex: 1;
line-height: 1em;
margin: -0.5em 0.5em 1.5em 0.5em;
`,
tag: css`
margin: 0 0.5em 0 0;
`,
url: css`
margin: -2.5px 0.3em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 30%;
display: inline-block;
`,
divider: css`
margin: 0 0.75em;
`,
header: css`
label: TracePageHeader;
background-color: ${theme.colors.background.primary};
position: sticky;
top: 0;
z-index: 5;
padding: 0.5em 0.25em 0 0.25em;
& > :last-child {
border-bottom: 1px solid ${autoColor(theme, '#ccc')};
}
`,
};
export type TracePageHeaderProps = {
trace: Trace | null;
timeZone: TimeZone;
search: SearchProps;
setSearch: React.Dispatch<React.SetStateAction<SearchProps>>;
showSpanFilters: boolean;
setShowSpanFilters: (isOpen: boolean) => void;
focusedSpanIdForSearch: string;
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
spanFilterMatches: Set<string> | undefined;
datasourceType: string;
setHeaderHeight: (height: number) => void;
};
export function NewTracePageHeader(props: TracePageHeaderEmbedProps) {
const { trace, updateNextViewRangeTime, updateViewRangeTime, viewRange, timeZone } = props;
export const NewTracePageHeader = memo((props: TracePageHeaderProps) => {
const {
trace,
timeZone,
search,
setSearch,
showSpanFilters,
setShowSpanFilters,
focusedSpanIdForSearch,
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
setHeaderHeight,
} = props;
const styles = { ...useStyles2(getStyles), ...useStyles2(getNewStyles) };
const links = React.useMemo(() => {
useEffect(() => {
setHeaderHeight(document.querySelector('.' + styles.header)?.scrollHeight ?? 0);
}, [setHeaderHeight, showSpanFilters, styles.header]);
const links = useMemo(() => {
if (!trace) {
return [];
}
@ -146,12 +130,62 @@ export function NewTracePageHeader(props: TracePageHeaderEmbedProps) {
)}
</div>
<SpanGraph
<SpanFilters
trace={trace}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
showSpanFilters={showSpanFilters}
setShowSpanFilters={setShowSpanFilters}
search={search}
setSearch={setSearch}
spanFilterMatches={spanFilterMatches}
focusedSpanIdForSearch={focusedSpanIdForSearch}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
datasourceType={datasourceType}
/>
</header>
);
}
});
NewTracePageHeader.displayName = 'NewTracePageHeader';
const getNewStyles = (theme: GrafanaTheme2) => {
return {
header: css`
label: TracePageHeader;
background-color: ${theme.colors.background.primary};
padding: 0.5em 0 0 0;
position: sticky;
top: 0;
z-index: 5;
`,
titleRow: css`
align-items: center;
display: flex;
padding: 0 8px;
`,
title: css`
color: inherit;
flex: 1;
font-size: 1.7em;
line-height: 1em;
`,
subtitle: css`
flex: 1;
line-height: 1em;
margin: -0.5em 8px 0.75em 8px;
`,
tag: css`
margin: 0 0.5em 0 0;
`,
url: css`
margin: -2.5px 0.3em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 30%;
display: inline-block;
`,
divider: css`
margin: 0 0.75em;
`,
};
};

View File

@ -0,0 +1,51 @@
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { render, screen } from '@testing-library/react';
import React from 'react';
import { defaultFilters } from '../../useSearch';
import NewTracePageSearchBar, { TracePageSearchBarProps } from './NewTracePageSearchBar';
const defaultProps = {
search: defaultFilters,
setFocusedSpanIdForSearch: jest.fn(),
};
describe('<NewTracePageSearchBar>', () => {
it('renders buttons', () => {
render(<NewTracePageSearchBar {...(defaultProps as unknown as TracePageSearchBarProps)} />);
const nextResButton = screen.queryByRole('button', { name: 'Next result button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect((nextResButton as HTMLButtonElement)['disabled']).toBe(true);
expect((prevResButton as HTMLButtonElement)['disabled']).toBe(true);
});
it('renders buttons that can be used to search if filters added', () => {
const props = {
...defaultProps,
spanFilterMatches: new Set(['2ed38015486087ca']),
};
render(<NewTracePageSearchBar {...(props as unknown as TracePageSearchBarProps)} />);
const nextResButton = screen.queryByRole('button', { name: 'Next result button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false);
expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false);
});
});

View File

@ -0,0 +1,123 @@
// Copyright (c) 2018 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import React, { memo, Dispatch, SetStateAction, useEffect } from 'react';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { SearchProps } from '../../useSearch';
export type TracePageSearchBarProps = {
search: SearchProps;
setSearch: React.Dispatch<React.SetStateAction<SearchProps>>;
spanFilterMatches: Set<string> | undefined;
focusedSpanIdForSearch: string;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
datasourceType: string;
};
export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) {
const { search, spanFilterMatches, focusedSpanIdForSearch, setFocusedSpanIdForSearch, datasourceType } = props;
const styles = useStyles2(getStyles);
useEffect(() => {
setFocusedSpanIdForSearch('');
}, [search, setFocusedSpanIdForSearch]);
const nextResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'next',
});
const spanMatches = Array.from(spanFilterMatches!);
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch);
// new query || at end, go to start
if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) {
setFocusedSpanIdForSearch(spanMatches[0]);
return;
}
// get next
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]);
};
const prevResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'prev',
});
const spanMatches = Array.from(spanFilterMatches!);
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch);
// new query || at start, go to end
if (prevMatchedIndex === -1 || prevMatchedIndex === 0) {
setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]);
return;
}
// get prev
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]);
};
const buttonEnabled = spanFilterMatches && spanFilterMatches?.size > 0;
return (
<div className={styles.searchBar}>
<>
<Button
className={styles.button}
variant="secondary"
disabled={!buttonEnabled}
type="button"
fill={'outline'}
aria-label="Prev result button"
onClick={prevResult}
>
Prev
</Button>
<Button
className={styles.button}
variant="secondary"
disabled={!buttonEnabled}
type="button"
fill={'outline'}
aria-label="Next result button"
onClick={nextResult}
>
Next
</Button>
</>
</div>
);
});
export const getStyles = () => {
return {
searchBar: css`
display: flex;
justify-content: flex-end;
margin-top: 5px;
`,
button: css`
margin-left: 8px;
`,
};
};

View File

@ -0,0 +1,179 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import { defaultFilters } from '../../../useSearch';
import { Trace } from '../../types/trace';
import { SpanFilters } from './SpanFilters';
const trace: Trace = {
traceID: '1ed38015486087ca',
spans: [
{
traceID: '1ed38015486087ca',
spanID: '1ed38015486087ca',
operationName: 'Span0',
tags: [{ key: 'TagKey0', type: 'string', value: 'TagValue0' }],
process: {
serviceName: 'Service0',
tags: [{ key: 'ProcessKey0', type: 'string', value: 'ProcessValue0' }],
},
logs: [{ fields: [{ key: 'LogKey0', type: 'string', value: 'LogValue0' }] }],
},
{
traceID: '1ed38015486087ca',
spanID: '2ed38015486087ca',
operationName: 'Span1',
tags: [{ key: 'TagKey1', type: 'string', value: 'TagValue1' }],
process: {
serviceName: 'Service1',
tags: [{ key: 'ProcessKey1', type: 'string', value: 'ProcessValue1' }],
},
logs: [{ fields: [{ key: 'LogKey1', type: 'string', value: 'LogValue1' }] }],
},
],
} as unknown as Trace;
describe('SpanFilters', () => {
let user: ReturnType<typeof userEvent.setup>;
const SpanFiltersWithProps = () => {
const [search, setSearch] = useState(defaultFilters);
const props = {
trace: trace,
showSpanFilters: true,
setShowSpanFilters: jest.fn(),
search: search,
setSearch: setSearch,
spanFilterMatches: undefined,
focusedSpanIdForSearch: '',
setFocusedSpanIdForSearch: jest.fn(),
datasourceType: 'tempo',
};
return <SpanFilters {...props} />;
};
beforeEach(() => {
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
});
it('should render', () => {
expect(() => render(<SpanFiltersWithProps />)).not.toThrow();
});
it('should render filters', async () => {
render(<SpanFiltersWithProps />);
const serviceOperator = screen.getByLabelText('Select service name operator');
const serviceValue = screen.getByLabelText('Select service name');
const spanOperator = screen.getByLabelText('Select span name operator');
const spanValue = screen.getByLabelText('Select span name');
const fromOperator = screen.getByLabelText('Select from operator');
const fromValue = screen.getByLabelText('Select from value');
const toOperator = screen.getByLabelText('Select to operator');
const toValue = screen.getByLabelText('Select to value');
const tagKey = screen.getByLabelText('Select tag key');
const tagOperator = screen.getByLabelText('Select tag operator');
const tagValue = screen.getByLabelText('Select tag value');
const addTag = screen.getByLabelText('Add tag');
const removeTag = screen.getByLabelText('Remove tag');
expect(serviceOperator).toBeInTheDocument();
expect(getElemText(serviceOperator)).toBe('=');
expect(serviceValue).toBeInTheDocument();
expect(spanOperator).toBeInTheDocument();
expect(getElemText(spanOperator)).toBe('=');
expect(spanValue).toBeInTheDocument();
expect(fromOperator).toBeInTheDocument();
expect(getElemText(fromOperator)).toBe('>');
expect(fromValue).toBeInTheDocument();
expect(toOperator).toBeInTheDocument();
expect(getElemText(toOperator)).toBe('<');
expect(toValue).toBeInTheDocument();
expect(tagKey).toBeInTheDocument();
expect(tagOperator).toBeInTheDocument();
expect(getElemText(tagOperator)).toBe('=');
expect(tagValue).toBeInTheDocument();
expect(addTag).toBeInTheDocument();
expect(removeTag).toBeInTheDocument();
await user.click(serviceValue);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Service0')).toBeInTheDocument();
expect(screen.getByText('Service1')).toBeInTheDocument();
});
await user.click(spanValue);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Span0')).toBeInTheDocument();
expect(screen.getByText('Span1')).toBeInTheDocument();
});
await user.click(tagKey);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('TagKey0')).toBeInTheDocument();
expect(screen.getByText('TagKey1')).toBeInTheDocument();
expect(screen.getByText('ProcessKey0')).toBeInTheDocument();
expect(screen.getByText('ProcessKey1')).toBeInTheDocument();
expect(screen.getByText('LogKey0')).toBeInTheDocument();
expect(screen.getByText('LogKey1')).toBeInTheDocument();
});
});
it('should update filters', async () => {
render(<SpanFiltersWithProps />);
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');
expect(getElemText(serviceValue)).toBe('All service names');
await selectAndCheckValue(user, serviceValue, 'Service0');
expect(getElemText(spanValue)).toBe('All span names');
await selectAndCheckValue(user, spanValue, 'Span0');
await user.click(tagValue);
jest.advanceTimersByTime(1000);
await waitFor(() => expect(screen.getByText('No options found')).toBeInTheDocument());
expect(getElemText(tagKey)).toBe('Select tag');
await selectAndCheckValue(user, tagKey, 'TagKey0');
expect(getElemText(tagValue)).toBe('Select value');
await selectAndCheckValue(user, tagValue, 'TagValue0');
});
it('should allow adding/removing tags', async () => {
render(<SpanFiltersWithProps />);
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
await user.click(screen.getByLabelText('Add tag'));
jest.advanceTimersByTime(1000);
expect(screen.getAllByLabelText('Select tag key').length).toBe(2);
await user.click(screen.getAllByLabelText('Remove tag')[0]);
jest.advanceTimersByTime(1000);
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
});
});
const selectAndCheckValue = async (user: ReturnType<typeof userEvent.setup>, elem: HTMLElement, text: string) => {
await user.click(elem);
jest.advanceTimersByTime(1000);
await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument());
await user.click(screen.getByText(text));
jest.advanceTimersByTime(1000);
expect(screen.getByText(text)).toBeInTheDocument();
};
const getElemText = (elem: HTMLElement) => {
return elem.parentElement?.previousSibling?.textContent;
};

View File

@ -0,0 +1,407 @@
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import { uniq } from 'lodash';
import React, { useState, memo } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import {
Collapse,
HorizontalGroup,
Icon,
InlineField,
InlineFieldRow,
Input,
Select,
Tooltip,
useStyles2,
} from '@grafana/ui';
import { randomId, SearchProps, Tag } from '../../../useSearch';
import { Trace } from '../../types';
import NewTracePageSearchBar from '../NewTracePageSearchBar';
export type SpanFilterProps = {
trace: Trace;
search: SearchProps;
setSearch: React.Dispatch<React.SetStateAction<SearchProps>>;
showSpanFilters: boolean;
setShowSpanFilters: (isOpen: boolean) => void;
focusedSpanIdForSearch: string;
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
spanFilterMatches: Set<string> | undefined;
datasourceType: string;
};
export const SpanFilters = memo((props: SpanFilterProps) => {
const {
trace,
search,
setSearch,
showSpanFilters,
setShowSpanFilters,
focusedSpanIdForSearch,
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
} = props;
const styles = { ...useStyles2(getStyles) };
const [serviceNames, setServiceNames] = useState<Array<SelectableValue<string>>>();
const [spanNames, setSpanNames] = useState<Array<SelectableValue<string>>>();
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
if (!trace) {
return null;
}
const getServiceNames = () => {
if (!serviceNames) {
const serviceNames = trace.spans.map((span) => {
return span.process.serviceName;
});
setServiceNames(
uniq(serviceNames)
.sort()
.map((name) => {
return toOption(name);
})
);
}
};
const getSpanNames = () => {
if (!spanNames) {
const spanNames = trace.spans.map((span) => {
return span.operationName;
});
setSpanNames(
uniq(spanNames)
.sort()
.map((name) => {
return toOption(name);
})
);
}
};
const getTagKeys = () => {
if (!tagKeys) {
const keys: string[] = [];
trace.spans.forEach((span) => {
span.tags.forEach((tag) => {
keys.push(tag.key);
});
span.process.tags.forEach((tag) => {
keys.push(tag.key);
});
if (span.logs !== null) {
span.logs.forEach((log) => {
log.fields.forEach((field) => {
keys.push(field.key);
});
});
}
});
setTagKeys(
uniq(keys)
.sort()
.map((name) => {
return toOption(name);
})
);
}
};
const getTagValues = async (key: string) => {
const values: string[] = [];
trace.spans.forEach((span) => {
const tagValue = span.tags.find((t) => t.key === key)?.value;
if (tagValue) {
values.push(tagValue.toString());
}
const processTagValue = span.process.tags.find((t) => t.key === key)?.value;
if (processTagValue) {
values.push(processTagValue.toString());
}
if (span.logs !== null) {
span.logs.forEach((log) => {
const logsTagValue = log.fields.find((t) => t.key === key)?.value;
if (logsTagValue) {
values.push(logsTagValue.toString());
}
});
}
});
return uniq(values)
.sort()
.map((name) => {
return toOption(name);
});
};
const onTagChange = (tag: Tag, v: SelectableValue<string>) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, key: v?.value || '', value: undefined } : x;
}),
});
const loadTagValues = async () => {
if (v?.value) {
setTagValues({
...tagValues,
[tag.id]: await getTagValues(v.value),
});
} else {
// removed value
const updatedValues = { ...tagValues };
if (updatedValues[tag.id]) {
delete updatedValues[tag.id];
}
setTagValues(updatedValues);
}
};
loadTagValues();
};
const addTag = () => {
const tag = {
id: randomId(),
operator: '=',
};
setSearch({ ...search, tags: [...search.tags, tag] });
};
const removeTag = (id: string) => {
let tags = search.tags.filter((tag) => {
return tag.id !== id;
});
if (tags.length === 0) {
tags = [
{
id: randomId(),
operator: '=',
},
];
}
setSearch({ ...search, tags: tags });
};
const collapseLabel = (
<Tooltip
content="Filter your spans below. The more filters, the more specific the filtered spans."
placement="right"
>
<span id="collapse-label">
Span Filters
<Icon size="sm" name="info-circle" />
</span>
</Tooltip>
);
return (
<div className={styles.container}>
<Collapse label={collapseLabel} collapsible={true} isOpen={showSpanFilters} onToggle={setShowSpanFilters}>
<InlineFieldRow>
<InlineField label="Service Name" labelWidth={16}>
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select service name operator"
onChange={(v) => setSearch({ ...search, serviceNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.serviceNameOperator}
/>
<Select
aria-label="Select service name"
isClearable
onChange={(v) => setSearch({ ...search, serviceName: v?.value || '' })}
onOpenMenu={getServiceNames}
options={serviceNames}
placeholder="All service names"
value={search.serviceName}
/>
</HorizontalGroup>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Span Name" labelWidth={16}>
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select span name operator"
onChange={(v) => setSearch({ ...search, spanNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.spanNameOperator}
/>
<Select
aria-label="Select span name"
isClearable
onChange={(v) => setSearch({ ...search, spanName: v?.value || '' })}
onOpenMenu={getSpanNames}
options={spanNames}
placeholder="All span names"
value={search.spanName}
/>
</HorizontalGroup>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Duration" labelWidth={16}>
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select from operator"
onChange={(v) => setSearch({ ...search, fromOperator: v.value! })}
options={[toOption('>'), toOption('>=')]}
value={search.fromOperator}
/>
<Input
aria-label="Select from value"
onChange={(v) => setSearch({ ...search, from: v.currentTarget.value })}
placeholder="e.g. 100ms, 1.2s"
value={search.from || ''}
width={18}
/>
<Select
aria-label="Select to operator"
onChange={(v) => setSearch({ ...search, toOperator: v.value! })}
options={[toOption('<'), toOption('<=')]}
value={search.toOperator}
/>
<Input
aria-label="Select to value"
onChange={(v) => setSearch({ ...search, to: v.currentTarget.value })}
placeholder="e.g. 100ms, 1.2s"
value={search.to || ''}
width={18}
/>
</HorizontalGroup>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Tags" labelWidth={16} tooltip="Filter by tags, process tags or log fields in your spans.">
<div>
{search.tags.map((tag, i) => (
<div key={i}>
<HorizontalGroup spacing={'xs'} width={'auto'}>
<Select
aria-label={`Select tag key`}
isClearable
key={tag.key}
onChange={(v) => onTagChange(tag, v)}
onOpenMenu={getTagKeys}
options={tagKeys}
placeholder="Select tag"
value={tag.key}
/>
<Select
aria-label={`Select tag operator`}
onChange={(v) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, operator: v.value! } : x;
}),
});
}}
options={[toOption('='), toOption('!=')]}
value={tag.operator}
/>
<span className={styles.tagValues}>
<Select
aria-label={`Select tag value`}
isClearable
key={tag.value}
onChange={(v) => {
setSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.value || '' } : x;
}),
});
}}
options={tagValues[tag.id] ? tagValues[tag.id] : []}
placeholder="Select value"
value={tag.value}
/>
</span>
<AccessoryButton
aria-label={`Remove tag`}
variant={'secondary'}
icon={'times'}
onClick={() => removeTag(tag.id)}
title={'Remove tag'}
/>
<span className={styles.addTag}>
{search?.tags?.length && i === search.tags.length - 1 && (
<AccessoryButton
aria-label="Add tag"
variant={'secondary'}
icon={'plus'}
onClick={addTag}
title={'Add tag'}
/>
)}
</span>
</HorizontalGroup>
</div>
))}
</div>
</InlineField>
</InlineFieldRow>
<NewTracePageSearchBar
search={search}
setSearch={setSearch}
spanFilterMatches={spanFilterMatches}
focusedSpanIdForSearch={focusedSpanIdForSearch}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
datasourceType={datasourceType}
/>
</Collapse>
</div>
);
});
SpanFilters.displayName = 'SpanFilters';
const getStyles = () => {
return {
container: css`
margin: 0.5em 0 -8px 0;
z-index: 5;
& > div {
border-left: none;
border-right: none;
}
#collapse-label svg {
margin: -1px 0 0 10px;
}
`,
addTag: css`
margin: 0 0 0 10px;
`,
tagValues: css`
max-width: 200px;
`,
};
};

View File

@ -17,7 +17,7 @@ import React from 'react';
import { getTraceName } from '../model/trace-viewer';
import TracePageHeader, { TracePageHeaderEmbedProps } from './TracePageHeader';
import TracePageHeader, { TracePageHeaderProps } from './TracePageHeader';
export const trace = {
services: [{ name: 'serviceA', numberOfSpans: 1 }],
@ -93,7 +93,7 @@ export const trace = {
endTime: 1675605058644515,
};
const setup = (propOverrides?: TracePageHeaderEmbedProps) => {
const setup = (propOverrides?: TracePageHeaderProps) => {
const defaultProps = {
trace,
timeZone: '',
@ -113,7 +113,7 @@ describe('TracePageHeader test', () => {
});
it('should render nothing if a trace is not present', () => {
setup({ trace: null } as TracePageHeaderEmbedProps);
setup({ trace: null } as TracePageHeaderProps);
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument();
@ -155,7 +155,7 @@ describe('TracePageHeader test', () => {
{...({
trace: trace,
viewRange: { time: { current: [10, 20] } },
} as unknown as TracePageHeaderEmbedProps)}
} as unknown as TracePageHeaderProps)}
/>
);
expect(screen.queryAllByRole('listitem')).toHaveLength(5);

View File

@ -15,7 +15,7 @@
import { css } from '@emotion/css';
import cx from 'classnames';
import { get as _get, maxBy as _maxBy, values as _values } from 'lodash';
import * as React from 'react';
import React from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
@ -106,7 +106,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
};
};
export type TracePageHeaderEmbedProps = {
export type TracePageHeaderProps = {
trace: Trace | null;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
@ -156,7 +156,7 @@ export const HEADER_ITEMS = [
},
];
export default function TracePageHeader(props: TracePageHeaderEmbedProps) {
export default function TracePageHeader(props: TracePageHeaderProps) {
const { trace, updateNextViewRangeTime, updateViewRangeTime, viewRange, timeZone } = props;
const styles = useStyles2(getStyles);

View File

@ -28,7 +28,7 @@ const defaultProps = {
describe('<TracePageSearchBar>', () => {
describe('truthy textFilter', () => {
it('renders UiFindInput with correct props', () => {
it('renders SearchBarInput with correct props', () => {
render(<TracePageSearchBar {...(defaultProps as unknown as TracePageSearchBarProps)} />);
expect((screen.getByPlaceholderText('Find...') as HTMLInputElement)['value']).toEqual('value');
const suffix = screen.getByLabelText('Search bar suffix');

View File

@ -20,7 +20,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import UiFindInput from '../common/UiFindInput';
import SearchBarInput from '../common/SearchBarInput';
import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles';
// eslint-disable-next-line no-duplicate-imports
@ -102,7 +102,7 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
) : null;
const btnClass = cx(styles.TracePageSearchBarBtn, { [styles.TracePageSearchBarBtnDisabled]: !searchValue });
const uiFindInputInputProps = {
const SearchBarInputProps = {
className: cx(styles.TracePageSearchBarBar, ubFlexAuto),
name: 'search',
suffix,
@ -172,10 +172,10 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
return (
<div className={styles.TracePageSearchBar}>
<span className={ubJustifyEnd} style={{ display: 'flex' }}>
<UiFindInput
<SearchBarInput
onChange={setTraceSearch}
value={searchValue}
inputProps={uiFindInputInputProps}
inputProps={SearchBarInputProps}
allowClear={true}
/>
<>

View File

@ -258,7 +258,7 @@ export default class ListView extends React.Component<TListViewProps> {
getRowPosition = (index: number): { height: number; y: number } =>
this._yPositions.getRowPosition(index, this._getHeight);
scrollToIndex = (index: number) => {
scrollToIndex = (index: number, headerHeight: number) => {
// calculate the position of the list view relative to the scroll parent
const { scrollElement } = this.props;
const scrollElementTop = scrollElement?.getBoundingClientRect().top || 0;
@ -269,7 +269,7 @@ export default class ListView extends React.Component<TListViewProps> {
// hard code a small offset to leave a little bit of space above the focused span, so it is visually clear
// that there is content above
this.props.scrollElement?.scrollTo({ top: itemOffset + listViewOffset - 80 });
this.props.scrollElement?.scrollTo({ top: itemOffset + listViewOffset - headerHeight - 80 });
};
/**

View File

@ -117,6 +117,7 @@ type TVirtualizedTraceViewOwnProps = {
topOfViewRef?: RefObject<HTMLDivElement>;
topOfViewRefType?: TopOfViewRefType;
datasourceType: string;
headerHeight: number;
};
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
@ -204,7 +205,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
}
componentDidMount() {
this.scrollToSpan(this.props.focusedSpanId);
this.scrollToSpan(this.props.headerHeight, this.props.focusedSpanId);
}
shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) {
@ -226,7 +227,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
}
componentDidUpdate(prevProps: Readonly<VirtualizedTraceViewProps>) {
const { registerAccessors, trace } = prevProps;
const { registerAccessors, trace, headerHeight } = prevProps;
const {
shouldScrollToFirstUiFindMatch,
clearShouldScrollToFirstUiFindMatch,
@ -253,11 +254,11 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
}
if (focusedSpanId !== prevProps.focusedSpanId) {
this.scrollToSpan(focusedSpanId);
this.scrollToSpan(headerHeight, focusedSpanId);
}
if (focusedSpanIdForSearch !== prevProps.focusedSpanIdForSearch) {
this.scrollToSpan(focusedSpanIdForSearch);
this.scrollToSpan(headerHeight, focusedSpanIdForSearch);
}
}
@ -368,13 +369,13 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
: this.renderSpanBarRow(span, spanIndex, key, style, attrs);
};
scrollToSpan = (spanID?: string) => {
scrollToSpan = (headerHeight: number, spanID?: string) => {
if (spanID == null) {
return;
}
const i = this.getRowStates().findIndex((row) => row.span.spanID === spanID);
if (i >= 0) {
this.listView?.scrollToIndex(i);
this.listView?.scrollToIndex(i, headerHeight);
}
};

View File

@ -113,6 +113,7 @@ export type TProps = TExtractUiFindFromStateReturn & {
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
topOfViewRefType?: TopOfViewRefType;
headerHeight: number;
};
type State = {

View File

@ -15,22 +15,22 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import UiFindInput from './UiFindInput';
import SearchBarInput from './SearchBarInput';
describe('UiFindInput', () => {
describe('SearchBarInput', () => {
describe('rendering', () => {
it('renders as expected with no value', () => {
render(<UiFindInput />);
const uiFindInput = screen.queryByPlaceholderText('Find...');
expect(uiFindInput).toBeInTheDocument();
expect(uiFindInput?.getAttribute('value')).toEqual('');
render(<SearchBarInput />);
const searchBarInput = screen.queryByPlaceholderText('Find...');
expect(searchBarInput).toBeInTheDocument();
expect(searchBarInput?.getAttribute('value')).toEqual('');
});
it('renders as expected with value', () => {
render(<UiFindInput value="value" />);
const uiFindInput = screen.queryByPlaceholderText('Find...');
expect(uiFindInput).toBeInTheDocument();
expect(uiFindInput?.getAttribute('value')).toEqual('value');
render(<SearchBarInput value="value" />);
const searchBarInput = screen.queryByPlaceholderText('Find...');
expect(searchBarInput).toBeInTheDocument();
expect(searchBarInput?.getAttribute('value')).toEqual('value');
});
});
});

View File

@ -27,7 +27,7 @@ type Props = {
onChange: (value: string) => void;
};
export default class UiFindInput extends React.PureComponent<Props> {
export default class SearchBarInput extends React.PureComponent<Props> {
static defaultProps: Partial<Props> = {
inputProps: {},
trackFindFunction: undefined,

View File

@ -6,5 +6,5 @@ export * from './types';
export * from './TraceTimelineViewer/types';
export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailState';
export { default as transformTraceData } from './model/transform-trace-data';
export { default as filterSpans } from './utils/filter-spans';
export { filterSpansNewTraceView, filterSpans } from './utils/filter-spans';
export * from './Theme';

View File

@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { defaultFilters, defaultTagFilter } from '../../useSearch';
import { TraceSpan } from '../types';
import filterSpans from './filter-spans';
import { filterSpans, filterSpansNewTraceView } from './filter-spans';
describe('filterSpans', () => {
// span0 contains strings that end in 0 or 1
@ -22,6 +23,7 @@ describe('filterSpans', () => {
const span0 = {
spanID: spanID0,
operationName: 'operationName0',
duration: 3050,
process: {
serviceName: 'serviceName0',
tags: [
@ -66,6 +68,7 @@ describe('filterSpans', () => {
const span2 = {
spanID: spanID2,
operationName: 'operationName2',
duration: 5000,
process: {
serviceName: 'serviceName2',
tags: [
@ -106,6 +109,371 @@ describe('filterSpans', () => {
};
const spans = [span0, span2] as TraceSpan[];
it('should return `undefined` if spans is falsy', () => {
expect(filterSpansNewTraceView({ ...defaultFilters, spanName: 'operationName' }, null)).toBe(undefined);
});
// Service / span name
it('should return spans whose serviceName match a filter', () => {
expect(filterSpansNewTraceView({ ...defaultFilters, serviceName: 'serviceName0' }, spans)).toEqual(
new Set([spanID0])
);
expect(filterSpansNewTraceView({ ...defaultFilters, serviceName: 'serviceName2' }, spans)).toEqual(
new Set([spanID2])
);
expect(
filterSpansNewTraceView({ ...defaultFilters, serviceName: 'serviceName2', serviceNameOperator: '!=' }, spans)
).toEqual(new Set([spanID0]));
});
it('should return spans whose operationName match a filter', () => {
expect(filterSpansNewTraceView({ ...defaultFilters, spanName: 'operationName0' }, spans)).toEqual(
new Set([spanID0])
);
expect(filterSpansNewTraceView({ ...defaultFilters, spanName: 'operationName2' }, spans)).toEqual(
new Set([spanID2])
);
expect(
filterSpansNewTraceView({ ...defaultFilters, spanName: 'operationName2', spanNameOperator: '!=' }, spans)
).toEqual(new Set([spanID0]));
});
// Durations
it('should return spans whose duration match a filter', () => {
expect(filterSpansNewTraceView({ ...defaultFilters, from: '2ms' }, spans)).toEqual(new Set([spanID0, spanID2]));
expect(filterSpansNewTraceView({ ...defaultFilters, from: '3.05ms' }, spans)).toEqual(new Set([spanID2]));
expect(filterSpansNewTraceView({ ...defaultFilters, from: '3.05ms', fromOperator: '>=' }, spans)).toEqual(
new Set([spanID0, spanID2])
);
expect(
filterSpansNewTraceView({ ...defaultFilters, from: '3.05ms', fromOperator: '>=', to: '4ms' }, spans)
).toEqual(new Set([spanID0]));
expect(filterSpansNewTraceView({ ...defaultFilters, to: '4ms' }, spans)).toEqual(new Set([spanID0]));
expect(filterSpansNewTraceView({ ...defaultFilters, to: '5ms', toOperator: '<=' }, spans)).toEqual(
new Set([spanID0, spanID2])
);
});
// Tags
it('should return spans whose tags kv.key match a filter', () => {
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey1' }] }, spans)
).toEqual(new Set([spanID0, spanID2]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey0' }] }, spans)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey2' }] }, spans)
).toEqual(new Set([spanID2]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey2', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID0]));
});
it('should return spans whose process.tags kv.key match a filter', () => {
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'processTagKey1' }] }, spans)
).toEqual(new Set([spanID0, spanID2]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'processTagKey0' }] }, spans)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'processTagKey2' }] }, spans)
).toEqual(new Set([spanID2]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'processTagKey2', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID0]));
});
it('should return spans whose logs have a field whose kv.key match a filter', () => {
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'logFieldKey1' }] }, spans)
).toEqual(new Set([spanID0, spanID2]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'logFieldKey0' }] }, spans)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'logFieldKey2' }] }, spans)
).toEqual(new Set([spanID2]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'logFieldKey2', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID0]));
});
it('should return no spans when logs is null', () => {
const nullSpan = { ...span0, logs: null };
expect(
filterSpansNewTraceView({ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'logFieldKey1' }] }, [
nullSpan,
] as unknown as TraceSpan[])
).toEqual(new Set([]));
});
it("should return spans whose tags' kv.key and kv.value match a filter", () => {
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1' }] },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID2]));
});
it("should not return spans whose tags' kv.key match a filter but kv.value/operator does not match", () => {
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey1', operator: '!=' }] },
spans
)
).toEqual(new Set());
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey2', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, tags: [{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1', operator: '!=' }] },
spans
)
).toEqual(new Set([spanID2]));
});
it('should return spans with multiple tag filters', () => {
// tags in same span
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey1' },
{ ...defaultTagFilter, key: 'tagKey0' },
],
},
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1' },
{ ...defaultTagFilter, key: 'tagKey0' },
],
},
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1' },
{ ...defaultTagFilter, key: 'tagKey0', value: 'tagValue0' },
],
},
spans
)
).toEqual(new Set([spanID0]));
// tags in different spans
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey0' },
{ ...defaultTagFilter, key: 'tagKey2' },
],
},
spans
)
).toEqual(new Set());
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey0', value: '' },
{ ...defaultTagFilter, key: 'tagKey2' },
],
},
spans
)
).toEqual(new Set());
// values in different spans
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey0', value: 'tagValue0' },
{ ...defaultTagFilter, key: 'tagKey2' },
],
},
spans
)
).toEqual(new Set());
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey0', value: 'tagValue0' },
{ ...defaultTagFilter, key: 'tagKey2', value: 'tagValue2' },
],
},
spans
)
).toEqual(new Set());
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1' },
{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue2' },
],
},
spans
)
).toEqual(new Set());
expect(
filterSpansNewTraceView(
{
...defaultFilters,
tags: [
{ ...defaultTagFilter, key: 'tagKey1', value: 'tagValue1' },
{ ...defaultTagFilter, key: 'tagKey2', value: 'tagValue2' },
],
},
spans
)
).toEqual(new Set());
});
// Multiple
it('should return spans with multiple filters', () => {
// service name + span name
expect(
filterSpansNewTraceView({ ...defaultFilters, serviceName: 'serviceName0', spanName: 'operationName0' }, spans)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView({ ...defaultFilters, serviceName: 'serviceName0', spanName: 'operationName2' }, spans)
).toEqual(new Set([]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName0', spanName: 'operationName2', spanNameOperator: '!=' },
spans
)
).toEqual(new Set([spanID0]));
// service name + span name + duration
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName0', spanName: 'operationName0', from: '2ms' },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName0', spanName: 'operationName0', to: '2ms' },
spans
)
).toEqual(new Set([]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName2', spanName: 'operationName2', to: '6ms' },
spans
)
).toEqual(new Set([spanID2]));
// service name + tag key
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName0', tags: [{ ...defaultTagFilter, key: 'tagKey0' }] },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName0', tags: [{ ...defaultTagFilter, key: 'tagKey1' }] },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName2', tags: [{ ...defaultTagFilter, key: 'tagKey1' }] },
spans
)
).toEqual(new Set([spanID2]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, serviceName: 'serviceName2', tags: [{ ...defaultTagFilter, key: 'tagKey2' }] },
spans
)
).toEqual(new Set([spanID2]));
expect(
filterSpansNewTraceView(
{
...defaultFilters,
serviceName: 'serviceName0',
tags: [{ ...defaultTagFilter, key: 'tagKey1', operator: '!=' }],
},
spans
)
).toEqual(new Set());
// duration + tag
expect(
filterSpansNewTraceView(
{ ...defaultFilters, from: '2ms', tags: [{ ...defaultTagFilter, key: 'tagKey0' }] },
spans
)
).toEqual(new Set([spanID0]));
expect(
filterSpansNewTraceView(
{ ...defaultFilters, to: '5ms', toOperator: '<=', tags: [{ ...defaultTagFilter, key: 'tagKey2' }] },
spans
)
).toEqual(new Set([spanID2]));
// all
expect(
filterSpansNewTraceView(
{
...defaultFilters,
serviceName: 'serviceName0',
spanName: 'operationName2',
spanNameOperator: '!=',
from: '3.05ms',
fromOperator: '>=',
to: '3.5ms',
tags: [{ ...defaultTagFilter, key: 'tagKey2', operator: '!=' }],
},
spans
)
).toEqual(new Set([spanID0]));
});
it('should return `undefined` if spans is falsy', () => {
expect(filterSpans('operationName', null)).toBe(undefined);
});

View File

@ -12,9 +12,147 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { SearchProps, Tag } from '../../useSearch';
import { TNil, TraceKeyValuePair, TraceSpan } from '../types';
export default function filterSpans(textFilter: string, spans: TraceSpan[] | TNil) {
// 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 filterSpansNewTraceView(searchProps: SearchProps, spans: TraceSpan[] | TNil) {
if (!spans) {
return undefined;
}
let filteredSpans = false;
if (searchProps.serviceName) {
spans = getServiceNameMatches(spans, searchProps);
filteredSpans = true;
}
if (searchProps.spanName) {
spans = getSpanNameMatches(spans, searchProps);
filteredSpans = true;
}
if (searchProps.from || searchProps.to) {
spans = getDurationMatches(spans, searchProps);
filteredSpans = true;
}
const tagMatches = getTagMatches(spans, searchProps.tags);
if (tagMatches) {
spans = tagMatches;
filteredSpans = true;
}
return filteredSpans ? new Set(spans.map((span: TraceSpan) => span.spanID)) : undefined;
}
const getTagMatches = (spans: TraceSpan[], tags: Tag[]) => {
// remove empty/default tags
tags = tags.filter((tag) => {
// tag.key === '' when it is cleared via pressing x icon in select field
return (tag.key && tag.key !== '') || tag.value;
});
if (tags.length > 0) {
return spans.filter((span: TraceSpan) => {
// match against every tag filter
return tags.every((tag: Tag) => {
if (tag.key && tag.value) {
if (span.tags.some((kv) => checkKeyAndValueForMatch(tag, kv))) {
return getReturnValue(tag.operator, true);
} else if (span.process.tags.some((kv) => checkKeyAndValueForMatch(tag, kv))) {
return getReturnValue(tag.operator, true);
} else if (span.logs.some((log) => log.fields.some((kv) => checkKeyAndValueForMatch(tag, kv)))) {
return getReturnValue(tag.operator, true);
}
} else if (tag.key) {
if (span.tags.some((kv) => checkKeyForMatch(tag.key!, kv.key))) {
return getReturnValue(tag.operator, true);
} else if (span.process.tags.some((kv) => checkKeyForMatch(tag.key!, kv.key))) {
return getReturnValue(tag.operator, true);
} else if (
span.logs &&
span.logs.some((log) => log.fields.some((kv) => checkKeyForMatch(tag.key!, kv.key)))
) {
return getReturnValue(tag.operator, true);
}
}
return getReturnValue(tag.operator, false);
});
});
}
return undefined;
};
const checkKeyForMatch = (tagKey: string, key: string) => {
return tagKey === key.toString() ? true : false;
};
const checkKeyAndValueForMatch = (tag: Tag, kv: TraceKeyValuePair) => {
return tag.key === kv.key.toString() && tag.value === kv.value.toString() ? true : false;
};
const getReturnValue = (operator: string, found: boolean) => {
return operator === '=' ? found : !found;
};
const getServiceNameMatches = (spans: TraceSpan[], searchProps: SearchProps) => {
return spans.filter((span: TraceSpan) => {
return searchProps.serviceNameOperator === '='
? span.process.serviceName === searchProps.serviceName
: span.process.serviceName !== searchProps.serviceName;
});
};
const getSpanNameMatches = (spans: TraceSpan[], searchProps: SearchProps) => {
return spans.filter((span: TraceSpan) => {
return searchProps.spanNameOperator === '='
? span.operationName === searchProps.spanName
: span.operationName !== searchProps.spanName;
});
};
const getDurationMatches = (spans: TraceSpan[], searchProps: SearchProps) => {
const from = convertTimeFilter(searchProps?.from || '');
const to = convertTimeFilter(searchProps?.to || '');
let filteredSpans: TraceSpan[] = [];
if (from) {
filteredSpans = spans.filter((span: TraceSpan) => {
return searchProps.fromOperator === '>' ? span.duration > from : span.duration >= from;
});
}
if (to) {
const filterForDuration = (span: TraceSpan) =>
searchProps.toOperator === '<' ? span.duration < to : span.duration <= to;
filteredSpans =
filteredSpans.length > 0
? filteredSpans.filter((span: TraceSpan) => {
return filterForDuration(span);
})
: spans.filter((span: TraceSpan) => {
return filterForDuration(span);
});
}
return filteredSpans;
};
export const convertTimeFilter = (time: string) => {
if (time.includes('μs')) {
return parseFloat(time.split('μs')[0]);
} else if (time.includes('ms')) {
return parseFloat(time.split('ms')[0]) * 1000;
} else if (time.includes('s')) {
return parseFloat(time.split('s')[0]) * 1000 * 1000;
} else if (time.includes('m')) {
return parseFloat(time.split('m')[0]) * 1000 * 1000 * 60;
} else if (time.includes('h')) {
return parseFloat(time.split('h')[0]) * 1000 * 1000 * 60 * 60;
}
return undefined;
};
// legacy code that will be removed when the newTraceView feature flag is removed
export function filterSpans(textFilter: string, spans: TraceSpan[] | TNil) {
if (!spans) {
return undefined;
}

View File

@ -1,36 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { TraceSpan } from './components';
import { useSearch } from './useSearch';
import { defaultFilters, useSearch, useSearchNewTraceView } from './useSearch';
describe('useSearch', () => {
const spans = [
{
spanID: 'span1',
operationName: 'operation1',
process: {
serviceName: 'service1',
tags: [],
},
tags: [],
logs: [],
} as unknown as TraceSpan,
{
spanID: 'span2',
operationName: 'operation2',
process: {
serviceName: 'service2',
tags: [],
},
tags: [],
logs: [],
} as unknown as TraceSpan,
];
it('returns matching span IDs', async () => {
const { result } = renderHook(() =>
useSearch([
{
spanID: 'span1',
operationName: 'operation1',
process: {
serviceName: 'service1',
tags: [],
},
tags: [],
logs: [],
} as unknown as TraceSpan,
const { result } = renderHook(() => useSearchNewTraceView(spans));
act(() => result.current.setNewTraceViewSearch({ ...defaultFilters, serviceName: 'service1' }));
expect(result.current.spanFilterMatches?.size).toBe(1);
expect(result.current.spanFilterMatches?.has('span1')).toBe(true);
});
{
spanID: 'span2',
operationName: 'operation2',
process: {
serviceName: 'service2',
tags: [],
},
tags: [],
logs: [],
} as unknown as TraceSpan,
])
);
it('works without spans', async () => {
const { result } = renderHook(() => useSearchNewTraceView());
act(() => result.current.setNewTraceViewSearch({ ...defaultFilters, serviceName: 'service1' }));
expect(result.current.spanFilterMatches).toBe(undefined);
});
it('returns matching span IDs', async () => {
const { result } = renderHook(() => useSearch(spans));
act(() => result.current.setSearch('service1'));
expect(result.current.spanFindMatches?.size).toBe(1);
expect(result.current.spanFindMatches?.has('span1')).toBe(true);
@ -38,7 +49,6 @@ describe('useSearch', () => {
it('works without spans', async () => {
const { result } = renderHook(() => useSearch());
act(() => result.current.setSearch('service1'));
expect(result.current.spanFindMatches).toBe(undefined);
});

View File

@ -1,11 +1,56 @@
import { useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { filterSpans, TraceSpan } from './components';
import { filterSpansNewTraceView, filterSpans, TraceSpan } from './components';
export interface SearchProps {
serviceName?: string;
serviceNameOperator: string;
spanName?: string;
spanNameOperator: string;
from?: string;
fromOperator: string;
to?: string;
toOperator: string;
tags: Tag[];
}
export interface Tag {
id: string;
key?: string;
operator: string;
value?: string;
}
export const randomId = () => uuidv4().slice(0, 12);
export const defaultTagFilter = {
id: randomId(),
operator: '=',
};
export const defaultFilters = {
spanNameOperator: '=',
serviceNameOperator: '=',
fromOperator: '>',
toOperator: '<',
tags: [defaultTagFilter],
};
/**
* Controls the state of search input that highlights spans if they match the search string.
* @param spans
*/
export function useSearchNewTraceView(spans?: TraceSpan[]) {
const [newTraceViewSearch, setNewTraceViewSearch] = useState<SearchProps>(defaultFilters);
const spanFilterMatches: Set<string> | undefined = useMemo(() => {
return spans && filterSpansNewTraceView(newTraceViewSearch, spans);
}, [newTraceViewSearch, spans]);
return { newTraceViewSearch, setNewTraceViewSearch, spanFilterMatches };
}
// legacy code that will be removed when the newTraceView feature flag is removed
export function useSearch(spans?: TraceSpan[]) {
const [search, setSearch] = useState('');
const spanFindMatches: Set<string> | undefined = useMemo(() => {

View File

@ -26,7 +26,6 @@ export const TracesPanel = ({ data }: PanelProps) => {
const dataSource = useAsync(async () => {
return await getDataSourceSrv().get(data.request?.targets[0].datasource?.uid);
});
const scrollElement = document.getElementsByClassName(styles.wrapper)[0];
const datasourceType = dataSource && dataSource.value ? dataSource.value.type : 'unknown';
if (!data || !data.series.length || !traceProp) {
@ -56,7 +55,7 @@ export const TracesPanel = ({ data }: PanelProps) => {
<TraceView
dataFrames={data.series}
scrollElement={scrollElement}
scrollElementClass={styles.wrapper}
traceProp={traceProp}
spanFindMatches={spanFindMatches}
search={search}