Trace View: Correctly handle span and service name in span filters (#115215)
* Correctly handle span name and service name in trace view span filters * Consistency and fix test * i18n extract
This commit is contained in:
-262
@@ -1,262 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants';
|
||||
|
||||
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' }],
|
||||
kind: 'server',
|
||||
statusCode: 2,
|
||||
statusMessage: 'message',
|
||||
instrumentationLibraryName: 'name',
|
||||
instrumentationLibraryVersion: 'version',
|
||||
traceState: 'state',
|
||||
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' }] }],
|
||||
},
|
||||
],
|
||||
processes: {
|
||||
'1ed38015486087ca': {
|
||||
serviceName: 'Service0',
|
||||
tags: [],
|
||||
},
|
||||
},
|
||||
} as unknown as Trace;
|
||||
|
||||
describe('SpanFilters', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
const SpanFiltersWithProps = ({ showFilters = true, matches }: { showFilters?: boolean; matches?: Set<string> }) => {
|
||||
const [search, setSearch] = useState(DEFAULT_SPAN_FILTERS);
|
||||
const props = {
|
||||
trace: trace,
|
||||
showSpanFilters: showFilters,
|
||||
setShowSpanFilters: jest.fn(),
|
||||
search,
|
||||
setSearch,
|
||||
spanFilterMatches: matches,
|
||||
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 min span operator');
|
||||
const fromValue = screen.getByLabelText('Select min span duration');
|
||||
const toOperator = screen.getByLabelText('Select max span operator');
|
||||
const toValue = screen.getByLabelText('Select max span duration');
|
||||
const tagKey = screen.getByLabelText('Select tag key');
|
||||
const tagOperator = screen.getByLabelText('Select tag operator');
|
||||
const tagSelectValue = screen.getByLabelText('Select tag value');
|
||||
|
||||
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(tagSelectValue).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(tagOperator);
|
||||
jest.advanceTimersByTime(1000);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('!~')).toBeInTheDocument();
|
||||
expect(screen.getByText('=~')).toBeInTheDocument();
|
||||
expect(screen.getByText('!~')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(tagKey);
|
||||
jest.advanceTimersByTime(1000);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TagKey0')).toBeInTheDocument();
|
||||
expect(screen.getByText('TagKey1')).toBeInTheDocument();
|
||||
expect(screen.getByText('kind')).toBeInTheDocument();
|
||||
expect(screen.getByText('ProcessKey0')).toBeInTheDocument();
|
||||
expect(screen.getByText('ProcessKey1')).toBeInTheDocument();
|
||||
expect(screen.getByText('LogKey0')).toBeInTheDocument();
|
||||
expect(screen.getByText('LogKey1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Find...')).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 tagOperator = screen.getByLabelText('Select tag operator');
|
||||
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');
|
||||
expect(screen.queryByLabelText('Input tag value')).toBeNull();
|
||||
await selectAndCheckValue(user, tagOperator, '=~');
|
||||
expect(screen.getByLabelText('Input tag value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should order tag filters', async () => {
|
||||
render(<SpanFiltersWithProps />);
|
||||
const tagKey = screen.getByLabelText('Select tag key');
|
||||
|
||||
await user.click(tagKey);
|
||||
jest.advanceTimersByTime(1000);
|
||||
await waitFor(() => {
|
||||
const container = screen.getByText('TagKey0').parentElement?.parentElement?.parentElement;
|
||||
expect(container?.childNodes[1].textContent).toBe('ProcessKey0');
|
||||
expect(container?.childNodes[2].textContent).toBe('ProcessKey1');
|
||||
expect(container?.childNodes[3].textContent).toBe('TagKey0');
|
||||
expect(container?.childNodes[4].textContent).toBe('TagKey1');
|
||||
expect(container?.childNodes[5].textContent).toBe('id');
|
||||
expect(container?.childNodes[6].textContent).toBe('kind');
|
||||
expect(container?.childNodes[7].textContent).toBe('library.name');
|
||||
expect(container?.childNodes[8].textContent).toBe('library.version');
|
||||
expect(container?.childNodes[9].textContent).toBe('status');
|
||||
expect(container?.childNodes[10].textContent).toBe('status.message');
|
||||
expect(container?.childNodes[11].textContent).toBe('trace.state');
|
||||
expect(container?.childNodes[12].textContent).toBe('LogKey0');
|
||||
expect(container?.childNodes[13].textContent).toBe('LogKey1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should only show add/remove tag when necessary', async () => {
|
||||
render(<SpanFiltersWithProps />);
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one
|
||||
expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
|
||||
|
||||
await selectAndCheckValue(user, screen.getByLabelText('Select tag key'), 'TagKey0');
|
||||
expect(screen.getAllByLabelText('Add tag').length).toBe(1);
|
||||
expect(screen.getAllByLabelText('Remove tag').length).toBe(1);
|
||||
|
||||
await user.click(screen.getByLabelText('Add tag'));
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one
|
||||
expect(screen.getAllByLabelText('Remove tag').length).toBe(2); // one for each tag
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(2);
|
||||
|
||||
await user.click(screen.getAllByLabelText('Remove tag')[1]);
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one
|
||||
expect(screen.queryAllByLabelText('Remove tag').length).toBe(1); // filled in the default tag, so can remove values
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
|
||||
|
||||
await user.click(screen.getAllByLabelText('Remove tag')[0]);
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one
|
||||
expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow adding/removing tags', async () => {
|
||||
render(<SpanFiltersWithProps />);
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
|
||||
const tagKey = screen.getByLabelText('Select tag key');
|
||||
await selectAndCheckValue(user, tagKey, 'TagKey0');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('renders buttons when span filters is collapsed', async () => {
|
||||
render(<SpanFiltersWithProps showFilters={false} />);
|
||||
expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Prev result button' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
-319
@@ -1,319 +0,0 @@
|
||||
// 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 React, { useState, useEffect, memo, useCallback, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2, TraceSearchProps, SelectableValue, toOption } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { IntervalInput } from '@grafana/o11y-ds-frontend';
|
||||
import { Collapse, Icon, InlineField, InlineFieldRow, Select, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DEFAULT_SPAN_FILTERS } from '../../../../state/constants';
|
||||
import { getTraceServiceNames, getTraceSpanNames } from '../../../utils/tags';
|
||||
import SearchBarInput from '../../common/SearchBarInput';
|
||||
import { Trace } from '../../types/trace';
|
||||
import NextPrevResult from '../SearchBar/NextPrevResult';
|
||||
import TracePageSearchBar from '../SearchBar/TracePageSearchBar';
|
||||
|
||||
import { SpanFiltersTags } from './SpanFiltersTags';
|
||||
|
||||
export type SpanFilterProps = {
|
||||
trace: Trace;
|
||||
search: TraceSearchProps;
|
||||
setSearch: (newSearch: TraceSearchProps) => void;
|
||||
showSpanFilters: boolean;
|
||||
setShowSpanFilters: (isOpen: boolean) => void;
|
||||
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
spanFilterMatches: Set<string> | undefined;
|
||||
datasourceType: string;
|
||||
};
|
||||
|
||||
export const SpanFilters = memo((props: SpanFilterProps) => {
|
||||
const {
|
||||
trace,
|
||||
search,
|
||||
setSearch,
|
||||
showSpanFilters,
|
||||
setShowSpanFilters,
|
||||
setFocusedSpanIdForSearch,
|
||||
spanFilterMatches,
|
||||
datasourceType,
|
||||
} = props;
|
||||
const styles = { ...useStyles2(getStyles) };
|
||||
const [serviceNames, setServiceNames] = useState<Array<SelectableValue<string>>>();
|
||||
const [spanNames, setSpanNames] = useState<Array<SelectableValue<string>>>();
|
||||
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
|
||||
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
|
||||
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
|
||||
const prevTraceIdRef = useRef<string>();
|
||||
|
||||
const durationRegex = /^\d+(?:\.\d)?\d*(?:ns|us|µs|ms|s|m|h)$/;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setServiceNames(undefined);
|
||||
setSpanNames(undefined);
|
||||
setTagKeys(undefined);
|
||||
setTagValues({});
|
||||
setSearch(DEFAULT_SPAN_FILTERS);
|
||||
}, [setSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only clear filters when trace ID actually changes (not on initial mount)
|
||||
const currentTraceId = trace?.traceID;
|
||||
|
||||
const traceHasChanged = prevTraceIdRef.current && prevTraceIdRef.current !== currentTraceId;
|
||||
|
||||
if (traceHasChanged) {
|
||||
clear();
|
||||
}
|
||||
|
||||
prevTraceIdRef.current = currentTraceId;
|
||||
}, [clear, trace]);
|
||||
|
||||
const setShowSpanFilterMatchesOnly = useCallback(
|
||||
(showMatchesOnly: boolean) => {
|
||||
setSearch({ ...search, matchesOnly: showMatchesOnly });
|
||||
},
|
||||
[search, setSearch]
|
||||
);
|
||||
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setSpanFiltersSearch = (spanSearch: TraceSearchProps) => {
|
||||
setFocusedSpanIndexForSearch(-1);
|
||||
setFocusedSpanIdForSearch('');
|
||||
setSearch(spanSearch);
|
||||
};
|
||||
|
||||
const getServiceNames = () => {
|
||||
if (!serviceNames) {
|
||||
setServiceNames(getTraceServiceNames(trace).map(toOption));
|
||||
}
|
||||
};
|
||||
|
||||
const getSpanNames = () => {
|
||||
if (!spanNames) {
|
||||
setSpanNames(getTraceSpanNames(trace).map(toOption));
|
||||
}
|
||||
};
|
||||
|
||||
const collapseLabel = (
|
||||
<>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'explore.span-filters.tooltip-collapse',
|
||||
'Filter your spans below. You can continue to apply filters until you have narrowed down your resulting spans to the select few you are most interested in.'
|
||||
)}
|
||||
placement="right"
|
||||
>
|
||||
<span className={styles.collapseLabel}>
|
||||
<Trans i18nKey="explore.span-filters.label-collapse">Span Filters</Trans>
|
||||
<Icon size="md" name="info-circle" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{!showSpanFilters && (
|
||||
<div className={styles.nextPrevResult}>
|
||||
<NextPrevResult
|
||||
trace={trace}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
datasourceType={datasourceType}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Collapse label={collapseLabel} isOpen={showSpanFilters} onToggle={setShowSpanFilters}>
|
||||
<InlineFieldRow className={styles.flexContainer}>
|
||||
<InlineField label={t('explore.span-filters.label-service-name', 'Service name')} labelWidth={16}>
|
||||
<Stack gap={0.5}>
|
||||
<Select
|
||||
aria-label={t(
|
||||
'explore.span-filters.aria-label-select-service-name-operator',
|
||||
'Select service name operator'
|
||||
)}
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, serviceNameOperator: v.value! })}
|
||||
options={[toOption('='), toOption('!=')]}
|
||||
value={search.serviceNameOperator}
|
||||
/>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters.aria-label-select-service-name', 'Select service name')}
|
||||
isClearable
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })}
|
||||
onOpenMenu={getServiceNames}
|
||||
options={serviceNames || (search.serviceName ? [search.serviceName].map(toOption) : [])}
|
||||
placeholder={t('explore.span-filters.placeholder-all-service-names', 'All service names')}
|
||||
value={search.serviceName || null}
|
||||
defaultValue={search.serviceName || null}
|
||||
/>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
<SearchBarInput
|
||||
onChange={(v) => {
|
||||
setSpanFiltersSearch({ ...search, query: v, matchesOnly: v !== '' });
|
||||
}}
|
||||
value={search.query || ''}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('explore.span-filters.label-span-name', 'Span name')} labelWidth={16}>
|
||||
<Stack gap={0.5}>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters.aria-label-select-span-name-operator', 'Select span name operator')}
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, spanNameOperator: v.value! })}
|
||||
options={[toOption('='), toOption('!=')]}
|
||||
value={search.spanNameOperator}
|
||||
/>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters.aria-label-select-span-name', 'Select span name')}
|
||||
isClearable
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, spanName: v?.value || '' })}
|
||||
onOpenMenu={getSpanNames}
|
||||
options={spanNames || (search.spanName ? [search.spanName].map(toOption) : [])}
|
||||
placeholder={t('explore.span-filters.placeholder-all-span-names', 'All span names')}
|
||||
value={search.spanName || null}
|
||||
/>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={t('explore.span-filters.label-duration', 'Duration')}
|
||||
labelWidth={16}
|
||||
tooltip={t('explore.span-filters.tooltip-duration', 'Filter by duration. Accepted units are {{units}}', {
|
||||
units: 'ns, us, ms, s, m, h',
|
||||
})}
|
||||
>
|
||||
<Stack alignItems="flex-start" gap={0.5}>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters.aria-label-select-min-span-operator', 'Select min span operator')}
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, fromOperator: v.value! })}
|
||||
options={[toOption('>'), toOption('>=')]}
|
||||
value={search.fromOperator}
|
||||
/>
|
||||
<div className={styles.intervalInput}>
|
||||
<IntervalInput
|
||||
ariaLabel={t('explore.span-filters.ariaLabel-select-min-span-duration', 'Select min span duration')}
|
||||
onChange={(val) => setSpanFiltersSearch({ ...search, from: val })}
|
||||
isInvalidError="Invalid duration"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="e.g. 100ms, 1.2s"
|
||||
width={18}
|
||||
value={search.from || ''}
|
||||
validationRegex={durationRegex}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters.aria-label-select-max-span-operator', 'Select max span operator')}
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, toOperator: v.value! })}
|
||||
options={[toOption('<'), toOption('<=')]}
|
||||
value={search.toOperator}
|
||||
/>
|
||||
<IntervalInput
|
||||
ariaLabel={t('explore.span-filters.ariaLabel-select-max-span-duration', 'Select max span duration')}
|
||||
onChange={(val) => setSpanFiltersSearch({ ...search, to: val })}
|
||||
isInvalidError="Invalid duration"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="e.g. 100ms, 1.2s"
|
||||
width={18}
|
||||
value={search.to || ''}
|
||||
validationRegex={durationRegex}
|
||||
/>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow className={styles.tagsRow}>
|
||||
<InlineField
|
||||
label={t('explore.span-filters.label-tags', 'Tags')}
|
||||
labelWidth={16}
|
||||
tooltip={t(
|
||||
'explore.span-filters.tooltip-tags',
|
||||
'Filter by tags, process tags or log fields in your spans.'
|
||||
)}
|
||||
>
|
||||
<SpanFiltersTags
|
||||
search={search}
|
||||
setSearch={setSpanFiltersSearch}
|
||||
trace={trace}
|
||||
tagKeys={tagKeys}
|
||||
setTagKeys={setTagKeys}
|
||||
tagValues={tagValues}
|
||||
setTagValues={setTagValues}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
<TracePageSearchBar
|
||||
trace={trace}
|
||||
search={search}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
datasourceType={datasourceType}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SpanFilters.displayName = 'SpanFilters';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
label: 'SpanFilters',
|
||||
margin: `0.5em 0 -${theme.spacing(1)} 0`,
|
||||
zIndex: 5,
|
||||
|
||||
'& > div': {
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
},
|
||||
}),
|
||||
collapseLabel: css({
|
||||
svg: {
|
||||
color: '#aaa',
|
||||
margin: '-2px 0 0 10px',
|
||||
},
|
||||
}),
|
||||
flexContainer: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
intervalInput: css({
|
||||
margin: '0 -4px 0 0',
|
||||
}),
|
||||
tagsRow: css({
|
||||
margin: '-4px 0 0 0',
|
||||
}),
|
||||
nextPrevResult: css({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useMount } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue, toOption, TraceSearchProps, TraceSearchTag } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { AccessoryButton } from '@grafana/plugin-ui';
|
||||
import { Input, Select, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { randomId } from '../../../../state/constants';
|
||||
import { getTraceTagKeys, getTraceTagValues } from '../../../utils/tags';
|
||||
import { Trace } from '../../types/trace';
|
||||
|
||||
interface Props {
|
||||
search: TraceSearchProps;
|
||||
setSearch: (search: TraceSearchProps) => void;
|
||||
trace: Trace;
|
||||
tagKeys?: Array<SelectableValue<string>>;
|
||||
setTagKeys: React.Dispatch<React.SetStateAction<Array<SelectableValue<string>> | undefined>>;
|
||||
tagValues: Record<string, Array<SelectableValue<string>>>;
|
||||
setTagValues: React.Dispatch<React.SetStateAction<{ [key: string]: Array<SelectableValue<string>> }>>;
|
||||
}
|
||||
|
||||
export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys, tagValues, setTagValues }: Props) => {
|
||||
const styles = { ...useStyles2(getStyles) };
|
||||
|
||||
const getTagKeys = () => {
|
||||
if (!tagKeys) {
|
||||
setTagKeys(getTraceTagKeys(trace).map(toOption));
|
||||
}
|
||||
};
|
||||
|
||||
const getTagValues = (key: string) => {
|
||||
return getTraceTagValues(trace, key).map(toOption);
|
||||
};
|
||||
|
||||
useMount(() => {
|
||||
if (search.tags) {
|
||||
search.tags.forEach((tag) => {
|
||||
if (tag.key) {
|
||||
setTagValues({
|
||||
...tagValues,
|
||||
[tag.id]: getTagValues(tag.key),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onTagChange = (tag: TraceSearchTag, v: SelectableValue<string>) => {
|
||||
setSearch({
|
||||
...search,
|
||||
tags: search.tags?.map((x) => {
|
||||
return x.id === tag.id ? { ...x, key: v?.value || '', value: undefined } : x;
|
||||
}),
|
||||
});
|
||||
|
||||
const loadTagValues = async () => {
|
||||
if (v?.value) {
|
||||
setTagValues({
|
||||
...tagValues,
|
||||
[tag.id]: getTagValues(v.value),
|
||||
});
|
||||
} else {
|
||||
// removed value
|
||||
const updatedValues = { ...tagValues };
|
||||
if (updatedValues[tag.id]) {
|
||||
delete updatedValues[tag.id];
|
||||
}
|
||||
setTagValues(updatedValues);
|
||||
}
|
||||
};
|
||||
loadTagValues();
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = {
|
||||
id: randomId(),
|
||||
operator: '=',
|
||||
};
|
||||
setSearch({ ...search, tags: [...search.tags, tag] });
|
||||
};
|
||||
|
||||
const removeTag = (id: string) => {
|
||||
let tags = search.tags.filter((tag) => {
|
||||
return tag.id !== id;
|
||||
});
|
||||
if (tags.length === 0) {
|
||||
tags = [
|
||||
{
|
||||
id: randomId(),
|
||||
operator: '=',
|
||||
},
|
||||
];
|
||||
}
|
||||
setSearch({ ...search, tags: tags });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{search.tags?.map((tag, i) => (
|
||||
<div key={tag.id}>
|
||||
<Stack gap={0} width={'auto'} justifyContent={'flex-start'} alignItems={'center'}>
|
||||
<div>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters-tags.aria-label-select-tag-key', 'Select tag key')}
|
||||
isClearable
|
||||
key={tag.key}
|
||||
onChange={(v) => onTagChange(tag, v)}
|
||||
onOpenMenu={getTagKeys}
|
||||
options={tagKeys || (tag.key ? [tag.key].map(toOption) : [])}
|
||||
placeholder={t('explore.span-filters-tags.placeholder-select-tag', 'Select tag')}
|
||||
value={tag.key || null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
aria-label={t('explore.span-filters-tags.aria-label-select-tag-operator', 'Select tag operator')}
|
||||
onChange={(v) => {
|
||||
setSearch({
|
||||
...search,
|
||||
tags: search.tags?.map((x) => {
|
||||
return x.id === tag.id ? { ...x, operator: v.value! } : x;
|
||||
}),
|
||||
});
|
||||
}}
|
||||
options={[toOption('='), toOption('!='), toOption('=~'), toOption('!~')]}
|
||||
value={tag.operator}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.tagValues}>
|
||||
{(tag.operator === '=' || tag.operator === '!=') && (
|
||||
<Select
|
||||
aria-label={t('explore.span-filters-tags.aria-label-select-tag-value', 'Select tag value')}
|
||||
isClearable
|
||||
key={tag.value}
|
||||
onChange={(v) => {
|
||||
setSearch({
|
||||
...search,
|
||||
tags: search.tags?.map((x) => {
|
||||
return x.id === tag.id ? { ...x, value: v?.value || '' } : x;
|
||||
}),
|
||||
});
|
||||
}}
|
||||
options={tagValues[tag.id] ? tagValues[tag.id] : tag.value ? [tag.value].map(toOption) : []}
|
||||
placeholder={t('explore.span-filters-tags.placeholder-select-value', 'Select value')}
|
||||
value={tag.value}
|
||||
/>
|
||||
)}
|
||||
{(tag.operator === '=~' || tag.operator === '!~') && (
|
||||
<Input
|
||||
aria-label={t('explore.span-filters-tags.aria-label-input-tag-value', 'Input tag value')}
|
||||
onChange={(v) => {
|
||||
setSearch({
|
||||
...search,
|
||||
tags: search.tags?.map((x) => {
|
||||
return x.id === tag.id ? { ...x, value: v?.currentTarget?.value || '' } : x;
|
||||
}),
|
||||
});
|
||||
}}
|
||||
placeholder={t('explore.span-filters-tags.placeholder-tag-value', 'Tag value')}
|
||||
width={18}
|
||||
value={tag.value || ''}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{(tag.key || tag.value || search.tags.length > 1) && (
|
||||
<AccessoryButton
|
||||
aria-label={t('explore.span-filters-tags.aria-label-remove-tag', 'Remove tag')}
|
||||
variant="secondary"
|
||||
icon="times"
|
||||
onClick={() => removeTag(tag.id)}
|
||||
tooltip={t('explore.span-filters-tags.tooltip-remove-tag', 'Remove tag')}
|
||||
/>
|
||||
)}
|
||||
{(tag.key || tag.value) && i === search.tags.length - 1 && (
|
||||
<span className={styles.addTag}>
|
||||
<AccessoryButton
|
||||
aria-label={t('explore.span-filters-tags.aria-label-add-tag', 'Add tag')}
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
onClick={addTag}
|
||||
tooltip={t('explore.span-filters-tags.tooltip-add-tag', 'Add tag')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
addTag: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
tagValues: css({
|
||||
maxWidth: '200px',
|
||||
}),
|
||||
});
|
||||
-15
@@ -1,18 +1,3 @@
|
||||
// Copyright (c) 2025 Grafana Labs
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { TraceSearchProps } from '@grafana/data';
|
||||
|
||||
@@ -5,3 +5,5 @@ export const LIBRARY_NAME = 'library.name';
|
||||
export const LIBRARY_VERSION = 'library.version';
|
||||
export const TRACE_STATE = 'trace.state';
|
||||
export const ID = 'id';
|
||||
export const SPAN_NAME = 'span.name';
|
||||
export const SERVICE_NAME = 'service.name';
|
||||
|
||||
@@ -16,7 +16,17 @@ import { SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
import { SelectableValue, TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data';
|
||||
|
||||
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../constants/span';
|
||||
import {
|
||||
KIND,
|
||||
LIBRARY_NAME,
|
||||
LIBRARY_VERSION,
|
||||
STATUS,
|
||||
STATUS_MESSAGE,
|
||||
TRACE_STATE,
|
||||
ID,
|
||||
SPAN_NAME,
|
||||
SERVICE_NAME,
|
||||
} from '../constants/span';
|
||||
import TNil from '../types/TNil';
|
||||
import { TraceSpan, CriticalPathSection } from '../types/trace';
|
||||
|
||||
@@ -46,13 +56,13 @@ const getAdhocFilterMatches = (spans: TraceSpan[], adhocFilters: Array<Selectabl
|
||||
return matchTextSearch(value, span);
|
||||
}
|
||||
|
||||
// Special handling for serviceName
|
||||
if (key === 'serviceName') {
|
||||
// Special handling for service.name
|
||||
if (key === SERVICE_NAME) {
|
||||
return matchField(span.process.serviceName, operator, value);
|
||||
}
|
||||
|
||||
// Special handling for spanName (operationName)
|
||||
if (key === 'spanName') {
|
||||
// Special handling for span.name
|
||||
if (key === SPAN_NAME) {
|
||||
return matchField(span.operationName, operator, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('useSearch', () => {
|
||||
// Check that adhoc filter was created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'serviceName',
|
||||
key: 'service.name',
|
||||
operator: '=',
|
||||
value: 'my-service',
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe('useSearch', () => {
|
||||
// Check that adhoc filter was created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'spanName',
|
||||
key: 'span.name',
|
||||
operator: '!=',
|
||||
value: 'my-operation',
|
||||
});
|
||||
@@ -195,13 +195,13 @@ describe('useSearch', () => {
|
||||
|
||||
// Verify each filter
|
||||
const filters = result.current.search.adhocFilters || [];
|
||||
expect(filters.find((f) => f.key === 'serviceName')).toMatchObject({
|
||||
key: 'serviceName',
|
||||
expect(filters.find((f) => f.key === 'service.name')).toMatchObject({
|
||||
key: 'service.name',
|
||||
operator: '=',
|
||||
value: 'my-service',
|
||||
});
|
||||
expect(filters.find((f) => f.key === 'spanName')).toMatchObject({
|
||||
key: 'spanName',
|
||||
expect(filters.find((f) => f.key === 'span.name')).toMatchObject({
|
||||
key: 'span.name',
|
||||
operator: '!=',
|
||||
value: 'my-operation',
|
||||
});
|
||||
@@ -306,7 +306,7 @@ describe('useSearch', () => {
|
||||
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 === 'service.name')?.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('=~');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDispatch, useSelector } from 'app/types/store';
|
||||
import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
|
||||
import { SPAN_NAME, SERVICE_NAME } from './components/constants/span';
|
||||
import { TraceSpan, CriticalPathSection } from './components/types/trace';
|
||||
import { filterSpans } from './components/utils/filter-spans';
|
||||
|
||||
@@ -25,7 +26,7 @@ export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProp
|
||||
// Migrate serviceName
|
||||
if (search.serviceName && search.serviceName.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: 'serviceName',
|
||||
key: SERVICE_NAME,
|
||||
operator: search.serviceNameOperator || '=',
|
||||
value: search.serviceName,
|
||||
});
|
||||
@@ -34,7 +35,7 @@ export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProp
|
||||
// Migrate spanName
|
||||
if (search.spanName && search.spanName.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: 'spanName',
|
||||
key: SPAN_NAME,
|
||||
operator: search.spanNameOperator || '=',
|
||||
value: search.spanName,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
STATUS,
|
||||
STATUS_MESSAGE,
|
||||
TRACE_STATE,
|
||||
SPAN_NAME,
|
||||
SERVICE_NAME,
|
||||
} from '../components/constants/span';
|
||||
import { Trace } from '../components/types/trace';
|
||||
|
||||
@@ -37,6 +39,11 @@ export const getTraceTagKeys = (trace: Trace) => {
|
||||
span.process.tags.forEach((tag) => {
|
||||
keys.push(tag.key);
|
||||
});
|
||||
|
||||
if (span.process.serviceName) {
|
||||
keys.push(SERVICE_NAME);
|
||||
}
|
||||
|
||||
if (span.logs !== null) {
|
||||
span.logs.forEach((log) => {
|
||||
log.fields.forEach((field) => {
|
||||
@@ -63,6 +70,9 @@ export const getTraceTagKeys = (trace: Trace) => {
|
||||
if (span.traceState) {
|
||||
keys.push(TRACE_STATE);
|
||||
}
|
||||
if (span.operationName) {
|
||||
keys.push(SPAN_NAME);
|
||||
}
|
||||
keys.push(ID);
|
||||
});
|
||||
keys = uniq(keys).sort();
|
||||
@@ -93,6 +103,11 @@ export const getTraceTagValues = (trace: Trace, key: string) => {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case SPAN_NAME:
|
||||
if (span.operationName) {
|
||||
values.push(span.operationName);
|
||||
}
|
||||
break;
|
||||
case KIND:
|
||||
if (span.kind) {
|
||||
values.push(span.kind);
|
||||
|
||||
@@ -7641,39 +7641,6 @@
|
||||
},
|
||||
"share-span": "Share"
|
||||
},
|
||||
"span-filters": {
|
||||
"aria-label-select-max-span-operator": "Select max span operator",
|
||||
"aria-label-select-min-span-operator": "Select min span operator",
|
||||
"aria-label-select-service-name": "Select service name",
|
||||
"aria-label-select-service-name-operator": "Select service name operator",
|
||||
"aria-label-select-span-name": "Select span name",
|
||||
"aria-label-select-span-name-operator": "Select span name operator",
|
||||
"ariaLabel-select-max-span-duration": "Select max span duration",
|
||||
"ariaLabel-select-min-span-duration": "Select min span duration",
|
||||
"label-collapse": "Span Filters",
|
||||
"label-duration": "Duration",
|
||||
"label-service-name": "Service name",
|
||||
"label-span-name": "Span name",
|
||||
"label-tags": "Tags",
|
||||
"placeholder-all-service-names": "All service names",
|
||||
"placeholder-all-span-names": "All span names",
|
||||
"tooltip-collapse": "Filter your spans below. You can continue to apply filters until you have narrowed down your resulting spans to the select few you are most interested in.",
|
||||
"tooltip-duration": "Filter by duration. Accepted units are {{units}}",
|
||||
"tooltip-tags": "Filter by tags, process tags or log fields in your spans."
|
||||
},
|
||||
"span-filters-tags": {
|
||||
"aria-label-add-tag": "Add tag",
|
||||
"aria-label-input-tag-value": "Input tag value",
|
||||
"aria-label-remove-tag": "Remove tag",
|
||||
"aria-label-select-tag-key": "Select tag key",
|
||||
"aria-label-select-tag-operator": "Select tag operator",
|
||||
"aria-label-select-tag-value": "Select tag value",
|
||||
"placeholder-select-tag": "Select tag",
|
||||
"placeholder-select-value": "Select value",
|
||||
"placeholder-tag-value": "Tag value",
|
||||
"tooltip-add-tag": "Add tag",
|
||||
"tooltip-remove-tag": "Remove tag"
|
||||
},
|
||||
"span-flame-graph": {
|
||||
"flame-graph": "Flame graph"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user