Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10745.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Add span status filters to trace details. Combine bug fixes. ([#10745](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10745))
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,15 @@ const traceTestSuite = () => {
});

describe('Filter Functionality', () => {
it('should show error filter and handle filtering', () => {
// Error filter button should exist
cy.getElementByTestId('error-count-button').should('be.visible').click();
it('should show status filter and handle error filtering', () => {
// Status filter button should exist - click to open popover
cy.getElementByTestId('span-status-filter-button').should('be.visible').click();

// Click the Error filter option in the popover
cy.getElementByTestId('status-filter-selectable')
.find('.euiSelectableList')
.contains('Error')
.click();

// Verify filter badge appears
cy.get('[data-test-subj^="filter-badge-"]').should('be.visible');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
EuiFlexItem,
EuiTitle,
EuiLink,
EuiIcon,
EuiText,
} from '@elastic/eui';
import type { MountPoint } from 'opensearch-dashboards/public';
import { useOpenSearchDashboards } from '../../../../../../../opensearch_dashboards_react/public';
Expand Down Expand Up @@ -79,10 +81,24 @@ export const TraceTopNavMenu: React.FC<TraceTopNavMenuProps> = ({
style={{ cursor: 'pointer' }}
data-test-subj="traceIdBadge"
>
{i18n.translate('explore.traceDetails.topNav.traceIdLabel', {
defaultMessage: 'Trace ID: {traceId}',
values: { traceId },
})}
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiText size="s">
{i18n.translate('explore.traceDetails.topNav.traceIdLabel', {
defaultMessage: 'Trace ID: {traceId}',
values: { traceId },
})}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon
type="copy"
size="s"
style={{ marginLeft: '4px' }}
aria-label="Copy Trace ID"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiBadge>
</EuiToolTip>
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ export function isSpanError(span: any): boolean {
return false;
}

export function isSpanOk(span: any): boolean {
if (!span) return false;

// If it's an error, it's not OK
return !isSpanError(span);
}

function extractHttpStatusCode(span: any): number | undefined {
if (!span) return undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiPanel } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { ChromeStart } from 'opensearch-dashboards/public';
Expand All @@ -19,6 +19,7 @@ export interface TraceFilter {
export function SpanDetailPanel(props: {
chrome: ChromeStart;
spanFilters: TraceFilter[];
setSpanFiltersWithStorage: (filters: TraceFilter[]) => void;
payloadData: string;
isGanttChartLoading?: boolean;
colorMap?: Record<string, string>;
Expand All @@ -28,7 +29,14 @@ export function SpanDetailPanel(props: {
isEmbedded?: boolean;
servicesInOrder?: string[];
}) {
const { chrome, spanFilters, payloadData, onSpanSelect, colorMap } = props;
const {
chrome,
spanFilters,
setSpanFiltersWithStorage,
payloadData,
onSpanSelect,
colorMap,
} = props;

const containerRef = useRef<HTMLDivElement | null>(null);
const [availableWidth, setAvailableWidth] = useState<number>(
Expand Down Expand Up @@ -108,6 +116,7 @@ export function SpanDetailPanel(props: {
availableWidth={availableWidth}
payloadData={payloadData}
filters={spanFilters}
setSpanFiltersWithStorage={setSpanFiltersWithStorage}
selectedSpanId={props.selectedSpanId}
colorMap={colorMap}
servicesInOrder={props.servicesInOrder}
Expand All @@ -118,6 +127,7 @@ export function SpanDetailPanel(props: {
onSpanSelect,
payloadData,
spanFilters,
setSpanFiltersWithStorage,
availableWidth,
props.selectedSpanId,
colorMap,
Expand All @@ -139,7 +149,6 @@ export function SpanDetailPanel(props: {
<div ref={containerRef}>
<EuiPanel data-test-subj="span-detail-panel">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiHorizontalRule margin="m" />
<EuiFlexItem className="exploreSpanDetailPanel__contentContainer">
{currentView === 'span_list' ? spanListTable : spanHierarchyTable}
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,13 @@ describe('SpanHierarchyTable', () => {
expect(toolbar).toBeInTheDocument();
});

it('passes ServiceLegendButton to secondaryToolbar', () => {
it('passes ServiceLegendButton and SpanStatusFilter to secondaryToolbar', () => {
render(<SpanHierarchyTable {...defaultProps} />);

const mockCall = mockRenderCustomDataGrid.mock.calls[0]?.[0];
expect(mockCall?.secondaryToolbar).toHaveLength(1);
expect(mockCall?.secondaryToolbar).toHaveLength(2);
expect(mockCall?.secondaryToolbar![0]).toBeDefined();
expect(mockCall?.secondaryToolbar![1]).toBeDefined();
});

it('displays only spans returned by applySpanFilters', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SpanCell } from './span_cell';
import { parseHits, applySpanFilters } from './utils';
import { ServiceLegendButton } from './service_legend_button';
import { getSpanHierarchyTableColumns } from './span_table_columns';
import { SpanStatusFilter } from './span_status_filter';

export const SpanHierarchyTable: React.FC<SpanTableProps> = (props) => {
const { availableWidth, openFlyout, colorMap, servicesInOrder = [] } = props;
Expand Down Expand Up @@ -175,6 +176,10 @@ export const SpanHierarchyTable: React.FC<SpanTableProps> = (props) => {
];

const secondaryToolbar = [
SpanStatusFilter({
spanFilters: props.filters,
setSpanFiltersWithStorage: props.setSpanFiltersWithStorage || (() => {}),
}),
<ServiceLegendButton
key="serviceLegend"
servicesInOrder={servicesInOrder}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiSelectable } from '@elastic/eui';
import { SpanStatusFilter, SpanStatusFilterProps } from './span_status_filter';
import { SpanFilter } from '../../../trace_view';

describe('SpanStatusFilter', () => {
let component: ShallowWrapper;
let mockSetSpanFiltersWithStorage: jest.Mock;

beforeEach(() => {
mockSetSpanFiltersWithStorage = jest.fn();
});

const createDefaultProps = (spanFilters: SpanFilter[] = []): SpanStatusFilterProps => ({
spanFilters,
setSpanFiltersWithStorage: mockSetSpanFiltersWithStorage,
});

describe('basic rendering', () => {
it('renders filter button and opens popover with selectable options', () => {
render(<SpanStatusFilter {...createDefaultProps()} />);

expect(screen.getByTestId('span-status-filter-button')).toBeInTheDocument();
expect(screen.getByText('Filter by status')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();

fireEvent.click(screen.getByTestId('span-status-filter-button'));

expect(screen.getByTestId('span-status-filter-popover')).toBeInTheDocument();
expect(screen.getByTestId('status-filter-selectable')).toBeInTheDocument();
});
});

describe('options and filter count', () => {
it('displays all three status options with correct labels', () => {
component = shallow(<SpanStatusFilter {...createDefaultProps()} />);

const options = component.find(EuiSelectable).prop('options');

expect(options).toHaveLength(3);
expect(options).toEqual([
expect.objectContaining({ label: 'Error', key: 'error' }),
expect.objectContaining({ label: 'OK', key: 'ok' }),
expect.objectContaining({ label: 'Unset', key: 'unset' }),
]);
});

it('shows correct count badge for active status filters', () => {
// Single filter
const { rerender } = render(
<SpanStatusFilter {...createDefaultProps([{ field: 'isError', value: true }])} />
);
expect(screen.getByText('1')).toBeInTheDocument();

// Multiple filters
rerender(
<SpanStatusFilter
{...createDefaultProps([
{ field: 'isError', value: true },
{ field: 'status.code', value: 1 },
{ field: 'status.code', value: 0 },
])}
/>
);
expect(screen.getByText('3')).toBeInTheDocument();

// Non-status filters ignored
rerender(
<SpanStatusFilter
{...createDefaultProps([
{ field: 'serviceName', value: 'test' },
{ field: 'isError', value: true },
])}
/>
);
expect(screen.getByText('1')).toBeInTheDocument();
});
});

describe('filter selection', () => {
it('adds correct filters when options are selected', () => {
component = shallow(<SpanStatusFilter {...createDefaultProps()} />);
const onChange = component.find(EuiSelectable).prop('onChange')!;

// Error option
onChange([
{ label: 'Error', key: 'error', checked: 'on' },
{ label: 'OK', key: 'ok' },
{ label: 'Unset', key: 'unset' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'isError', value: true },
]);

// OK option
onChange([
{ label: 'Error', key: 'error' },
{ label: 'OK', key: 'ok', checked: 'on' },
{ label: 'Unset', key: 'unset' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'status.code', value: 1 },
]);

// Unset option
onChange([
{ label: 'Error', key: 'error' },
{ label: 'OK', key: 'ok' },
{ label: 'Unset', key: 'unset', checked: 'on' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'status.code', value: 0 },
]);

// Multiple options
onChange([
{ label: 'Error', key: 'error', checked: 'on' },
{ label: 'OK', key: 'ok', checked: 'on' },
{ label: 'Unset', key: 'unset' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'isError', value: true },
{ field: 'status.code', value: 1 },
]);
});

it('preserves non-status filters when updating', () => {
const spanFilters = [
{ field: 'serviceName', value: 'test' },
{ field: 'isError', value: true },
];
component = shallow(<SpanStatusFilter {...createDefaultProps(spanFilters)} />);
const onChange = component.find(EuiSelectable).prop('onChange')!;

// Deselect all
onChange([
{ label: 'Error', key: 'error' },
{ label: 'OK', key: 'ok' },
{ label: 'Unset', key: 'unset' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'serviceName', value: 'test' },
]);

// Add new filter
onChange([
{ label: 'Error', key: 'error' },
{ label: 'OK', key: 'ok', checked: 'on' },
{ label: 'Unset', key: 'unset' },
]);
expect(mockSetSpanFiltersWithStorage).toHaveBeenCalledWith([
{ field: 'serviceName', value: 'test' },
{ field: 'status.code', value: 1 },
]);
});
});
});
Loading
Loading