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:
Andre Pereira
2025-12-18 12:38:50 +00:00
committed by GitHub
parent e29bb47e95
commit 241fd69e02
10 changed files with 42 additions and 845 deletions
@@ -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;
};
@@ -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),
}),
});
@@ -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',
}),
});
@@ -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);
-33
View File
@@ -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"
},