Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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