[Security Solution][Serverless] - Improve security solution performance (#194241)

## Summary

The goal of this PR is to improve the default performance of many of our
security solution views.

1. Upon scale testing, it was observed that the default events histogram
aggregation was a source of application slowness, so to improve the
performance of the default security experience, we've made the default
breakdown for the events histogram `No Breakdown` similar to what is
seen in the default discover histogram experience.

2. After looking through some telemetry, it was observed that the field
list query run in the background for timeline can also take a
significant amount of time based on the user's field count, so we now
only run that query after timeline has been opened.

### Demos
#### 1. By default the events visualizations on the overview and explore
events pages will not have an aggregation. The user will have to
manually select the breakdown they desire:
d354d27962


https://github.com/user-attachments/assets/a6d6987b-73fc-4735-9c37-973917c2fa2d


#### 2. Timeline fields list will only load after the first interaction
with timeline:
ad557260d8

**Before:**


https://github.com/user-attachments/assets/0ad2e903-ac15-4daa-925b-da8ad05e80dd


**After:**


https://github.com/user-attachments/assets/27d5d3d5-02c8-49b5-b699-239ebc36b16c
This commit is contained in:
Michael Olorunnisola 2024-09-27 14:45:45 -04:00 committed by GitHub
parent dab27867a6
commit e45d97b26c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 206 additions and 40 deletions

View file

@ -15,6 +15,7 @@ import { EventsQueryTabBody, ALERTS_EVENTS_HISTOGRAM_ID } from './events_query_t
import { useGlobalFullScreen } from '../../containers/use_full_screen';
import { licenseService } from '../../hooks/use_license';
import { mockHistory } from '../../mock/router';
import { DEFAULT_EVENTS_STACK_BY_VALUE } from './histogram_configurations';
const mockGetDefaultControlColumn = jest.fn();
jest.mock('../../../timelines/components/timeline/body/control_columns', () => ({
@ -144,7 +145,7 @@ describe('EventsQueryTabBody', () => {
);
expect(result.getByTestId('header-section-supplements').querySelector('select')?.value).toEqual(
'event.action'
DEFAULT_EVENTS_STACK_BY_VALUE
);
});

View file

@ -11,7 +11,9 @@ import { getEventsHistogramLensAttributes } from '../visualization_actions/lens_
import type { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types';
import * as i18n from './translations';
const DEFAULT_EVENTS_STACK_BY = 'event.action';
export const NO_BREAKDOWN_STACK_BY_VALUE = 'no_breakdown';
export const DEFAULT_EVENTS_STACK_BY_VALUE = NO_BREAKDOWN_STACK_BY_VALUE;
export const getSubtitleFunction =
(defaultNumberFormat: string, isAlert: boolean) => (totalCount: number) =>
@ -20,6 +22,10 @@ export const getSubtitleFunction =
}`;
export const eventsStackByOptions: MatrixHistogramOption[] = [
{
text: i18n.EVENTS_GRAPH_NO_BREAKDOWN_TITLE,
value: NO_BREAKDOWN_STACK_BY_VALUE,
},
{
text: 'event.action',
value: 'event.action',
@ -36,7 +42,8 @@ export const eventsStackByOptions: MatrixHistogramOption[] = [
export const eventsHistogramConfig: MatrixHistogramConfigs = {
defaultStackByOption:
eventsStackByOptions.find((o) => o.text === DEFAULT_EVENTS_STACK_BY) ?? eventsStackByOptions[0],
eventsStackByOptions.find((o) => o.value === DEFAULT_EVENTS_STACK_BY_VALUE) ??
eventsStackByOptions[0],
stackByOptions: eventsStackByOptions,
subtitle: undefined,
title: i18n.EVENTS_GRAPH_TITLE,
@ -58,7 +65,7 @@ const DEFAULT_STACK_BY = 'event.module';
export const alertsHistogramConfig: MatrixHistogramConfigs = {
defaultStackByOption:
alertsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? alertsStackByOptions[0],
alertsStackByOptions.find((o) => o.value === DEFAULT_STACK_BY) ?? alertsStackByOptions[0],
stackByOptions: alertsStackByOptions,
title: i18n.ALERTS_GRAPH_TITLE,
getLensAttributes: getExternalAlertLensAttributes,

View file

@ -54,3 +54,10 @@ export const SHOW_EXTERNAL_ALERTS = i18n.translate(
export const EVENTS_GRAPH_TITLE = i18n.translate('xpack.securitySolution.eventsGraphTitle', {
defaultMessage: 'Events',
});
export const EVENTS_GRAPH_NO_BREAKDOWN_TITLE = i18n.translate(
'xpack.securitySolution.eventsHistogram.selectOptions.noBreakDownLabel',
{
defaultMessage: 'No breakdown',
}
);

View file

@ -28,6 +28,7 @@ import { VISUALIZATION_ACTIONS_BUTTON_CLASS } from '../visualization_actions/uti
import { VisualizationEmbeddable } from '../visualization_actions/visualization_embeddable';
import { useVisualizationResponse } from '../visualization_actions/use_visualization_response';
import type { SourcererScopeName } from '../../../sourcerer/store/model';
import { NO_BREAKDOWN_STACK_BY_VALUE } from '../events_tab/histogram_configurations';
export type MatrixHistogramComponentProps = MatrixHistogramQueryProps &
MatrixHistogramConfigs & {
@ -165,6 +166,13 @@ export const MatrixHistogramComponent: React.FC<MatrixHistogramComponentProps> =
[isPtrIncluded, filterQuery]
);
// If the user selected the `No breakdown` option, we shouldn't perform the aggregation
const stackByField = useMemo(() => {
return selectedStackByOption.value === NO_BREAKDOWN_STACK_BY_VALUE
? undefined
: selectedStackByOption.value;
}, [selectedStackByOption.value]);
if (hideHistogram) {
return null;
}
@ -216,7 +224,7 @@ export const MatrixHistogramComponent: React.FC<MatrixHistogramComponentProps> =
id={visualizationId}
inspectTitle={title as string}
lensAttributes={lensAttributes}
stackByField={selectedStackByOption.value}
stackByField={stackByField}
timerange={timerange}
/>
) : null}

View file

@ -12,7 +12,7 @@ import type { GetLensAttributes, LensAttributes } from '../visualization_actions
export interface MatrixHistogramOption {
text: string;
value: string;
value: string | undefined;
}
export type GetSubTitle = (count: number) => string;

View file

@ -11,7 +11,7 @@ import { wrapper } from '../../mocks';
import { useLensAttributes } from '../../use_lens_attributes';
import { getEventsHistogramLensAttributes } from './events';
import { getEventsHistogramLensAttributes, stackByFieldAccessorId } from './events';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('0039eb0c-9a1a-4687-ae54-0f4e239bec75'),
@ -497,4 +497,41 @@ describe('getEventsHistogramLensAttributes', () => {
})
);
});
it('should render the layer for the stackByField when provided', () => {
const { result } = renderHook(
() =>
useLensAttributes({
getLensAttributes: getEventsHistogramLensAttributes,
stackByField: 'event.dataset',
}),
{ wrapper }
);
expect(result?.current?.state?.visualization).toEqual(
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({ splitAccessor: stackByFieldAccessorId }),
]),
})
);
});
it('should not render the layer for the stackByField is undefined', () => {
const { result } = renderHook(
() =>
useLensAttributes({
getLensAttributes: getEventsHistogramLensAttributes,
}),
{ wrapper }
);
expect(result?.current?.state?.visualization).toEqual(
expect.objectContaining({
layers: expect.arrayContaining([
expect.not.objectContaining({ splitAccessor: stackByFieldAccessorId }),
]),
})
);
});
});

View file

@ -8,9 +8,11 @@ import { v4 as uuidv4 } from 'uuid';
import type { GetLensAttributes } from '../../types';
const layerId = uuidv4();
// Exported for testing purposes
export const stackByFieldAccessorId = '34919782-4546-43a5-b668-06ac934d3acd';
export const getEventsHistogramLensAttributes: GetLensAttributes = (
stackByField = 'event.action',
stackByField,
extraOptions = {}
) => {
return {
@ -37,7 +39,7 @@ export const getEventsHistogramLensAttributes: GetLensAttributes = (
showGridlines: false,
layerType: 'data',
xAccessor: 'aac9d7d0-13a3-480a-892b-08207a787926',
splitAccessor: '34919782-4546-43a5-b668-06ac934d3acd',
splitAccessor: stackByField ? stackByFieldAccessorId : undefined,
},
],
yRightExtent: {
@ -83,30 +85,32 @@ export const getEventsHistogramLensAttributes: GetLensAttributes = (
sourceField: '___records___',
params: { emptyAsNull: true },
},
'34919782-4546-43a5-b668-06ac934d3acd': {
label: `Top values of ${stackByField}`,
dataType: 'string',
operationType: 'terms',
scale: 'ordinal',
sourceField: `${stackByField}`,
isBucketed: true,
params: {
size: 10,
orderBy: {
type: 'column',
columnId: 'e09e0380-0740-4105-becc-0a4ca12e3944',
},
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
parentFormat: {
id: 'terms',
...(stackByField && {
[stackByFieldAccessorId]: {
label: `Top values of ${stackByField}`,
dataType: 'string',
operationType: 'terms',
scale: 'ordinal',
sourceField: `${stackByField}`,
isBucketed: true,
params: {
size: 10,
orderBy: {
type: 'column',
columnId: 'e09e0380-0740-4105-becc-0a4ca12e3944',
},
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
parentFormat: {
id: 'terms',
},
},
},
},
}),
},
columnOrder: [
'34919782-4546-43a5-b668-06ac934d3acd',
...(stackByField ? [stackByFieldAccessorId] : []),
'aac9d7d0-13a3-480a-892b-08207a787926',
'e09e0380-0740-4105-becc-0a4ca12e3944',
],

View file

@ -22,6 +22,7 @@ import { useSourcererDataView } from '../../../sourcerer/containers';
import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric';
import { useRouteSpy } from '../../utils/route/use_route_spy';
import { SecurityPageName } from '../../../app/types';
import { getEventsHistogramLensAttributes } from './lens_attributes/common/events';
jest.mock('../../../sourcerer/containers');
jest.mock('../../utils/route/use_route_spy', () => ({
@ -212,6 +213,25 @@ describe('useLensAttributes', () => {
]);
});
it('should not set splitAccessor if stackByField is undefined', () => {
const { result } = renderHook(
() =>
useLensAttributes({
getLensAttributes: getEventsHistogramLensAttributes,
stackByField: undefined,
}),
{ wrapper }
);
expect(result?.current?.state?.visualization).toEqual(
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({ seriesType: 'bar_stacked', splitAccessor: undefined }),
]),
})
);
});
it('should return null if no indices exist', () => {
(useSourcererDataView as jest.Mock).mockReturnValue({
dataViewId: 'security-solution-default',

View file

@ -72,7 +72,7 @@ export const useLensAttributes = ({
() =>
lensAttributes ??
((getLensAttributes &&
stackByField &&
stackByField !== null &&
getLensAttributes(stackByField, extraOptions)) as LensAttributes),
[extraOptions, getLensAttributes, lensAttributes, stackByField]
);
@ -82,7 +82,7 @@ export const useLensAttributes = ({
const lensAttrsWithInjectedData = useMemo(() => {
if (
lensAttributes == null &&
(getLensAttributes == null || stackByField == null || stackByField?.length === 0)
(getLensAttributes == null || stackByField === null || stackByField?.length === 0)
) {
return null;
}

View file

@ -26,6 +26,7 @@ import { useKibana, useUiSetting$ } from '../../../common/lib/kibana';
import {
eventsStackByOptions,
eventsHistogramConfig,
NO_BREAKDOWN_STACK_BY_VALUE,
} from '../../../common/components/events_tab/histogram_configurations';
import { HostsTableType } from '../../../explore/hosts/store/model';
import type { GlobalTimeArgs } from '../../../common/containers/use_global_time';
@ -36,7 +37,7 @@ import { useFormatUrl } from '../../../common/components/link_to';
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
import type { SourcererScopeName } from '../../../sourcerer/store/model';
const DEFAULT_STACK_BY = 'event.dataset';
const DEFAULT_STACK_BY = NO_BREAKDOWN_STACK_BY_VALUE;
const ID = 'eventsByDatasetOverview';
const CHART_HEIGHT = 160;
@ -156,7 +157,7 @@ const EventsByDatasetComponent: React.FC<Props> = ({
defaultStackByOption:
onlyField != null
? getHistogramOption(onlyField)
: eventsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ??
: eventsStackByOptions.find((o) => o.value === DEFAULT_STACK_BY) ??
eventsStackByOptions[0],
legendPosition: Position.Right,
subtitle: (totalCount: number) =>

View file

@ -100,8 +100,11 @@ const TestComponent = (props: Partial<ComponentProps<typeof QueryTabContent>>) =
const dispatch = useDispatch();
// populating timeline so that it is not blank
useEffect(() => {
// Unified field list can be a culprit for long load times, so we wait for the timeline to be interacted with to load
dispatch(timelineActions.showTimeline({ id: TimelineId.test, show: true }));
// populating timeline so that it is not blank
dispatch(
timelineActions.applyKqlFilterQuery({
id: TimelineId.test,

View file

@ -92,7 +92,10 @@ const SPECIAL_TEST_TIMEOUT = 50000;
const localMockedTimelineData = structuredClone(mockTimelineData);
const TestComponent = (props: Partial<ComponentProps<typeof UnifiedTimeline>>) => {
const TestComponent = (
props: Partial<ComponentProps<typeof UnifiedTimeline>> & { show?: boolean }
) => {
const { show, ...restProps } = props;
const testComponentDefaultProps: ComponentProps<typeof QueryTabContent> = {
columns: getColumnHeaders(columnsToDisplay, mockSourcererScope.browserFields),
activeTab: TimelineTabs.query,
@ -119,8 +122,11 @@ const TestComponent = (props: Partial<ComponentProps<typeof UnifiedTimeline>>) =
const dispatch = useDispatch();
// populating timeline so that it is not blank
useEffect(() => {
// Unified field list can be a culprit for long load times, so we wait for the timeline to be interacted with to load
dispatch(timelineActions.showTimeline({ id: TimelineId.test, show: show ?? true }));
// populating timeline so that it is not blank
dispatch(
timelineActions.applyKqlFilterQuery({
id: TimelineId.test,
@ -133,9 +139,9 @@ const TestComponent = (props: Partial<ComponentProps<typeof UnifiedTimeline>>) =
},
})
);
}, [dispatch]);
}, [dispatch, show]);
return <UnifiedTimeline {...testComponentDefaultProps} {...props} />;
return <UnifiedTimeline {...testComponentDefaultProps} {...restProps} />;
};
const customStore = createMockStore();
@ -513,6 +519,51 @@ describe('unified timeline', () => {
});
describe('unified field list', () => {
describe('render', () => {
let TestProviderWithNewStore: FC<PropsWithChildren<unknown>>;
beforeEach(() => {
const freshStore = createMockStore();
// eslint-disable-next-line react/display-name
TestProviderWithNewStore = ({ children }) => {
return <TestProviders store={freshStore}>{children}</TestProviders>;
};
});
it(
'should not render when timeline has never been opened',
async () => {
render(<TestComponent show={false} />, {
wrapper: TestProviderWithNewStore,
});
expect(await screen.queryByTestId('timeline-sidebar')).not.toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
it(
'should render when timeline has been opened',
async () => {
render(<TestComponent />, {
wrapper: TestProviderWithNewStore,
});
expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
it(
'should not re-render when timeline has been opened at least once',
async () => {
const { rerender } = render(<TestComponent />, {
wrapper: TestProviderWithNewStore,
});
rerender(<TestComponent show={false} />);
// Even after timeline is closed, it should still exist in the background
expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
});
it(
'should be able to add filters',
async () => {

View file

@ -6,7 +6,7 @@
*/
import type { EuiDataGridProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui';
import React, { useMemo, useCallback, useState, useRef } from 'react';
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { generateFilters } from '@kbn/data-plugin/public';
@ -26,6 +26,7 @@ import { UnifiedFieldListSidebarContainer } from '@kbn/unified-field-list';
import type { EuiTheme } from '@kbn/react-kibana-context-styled';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { withDataView } from '../../../../common/components/with_data_view';
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
import type { TimelineItem } from '../../../../../common/search_strategy';
@ -47,6 +48,7 @@ import TimelineDataTable from './data_table';
import { timelineActions } from '../../../store';
import { getFieldsListCreationOptions } from './get_fields_list_creation_options';
import { defaultUdtHeaders } from './default_headers';
import { getTimelineShowStatusByIdSelector } from '../../../store/selectors';
const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({
className: `${className}`,
@ -343,6 +345,31 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
onFieldEdited();
}, [onFieldEdited]);
// PERFORMANCE ONLY CODE BLOCK
/**
* We check for the timeline open status to request the fields for the fields browser as the fields request
* is often a much longer running request for customers with a significant number of indices and fields in those indices.
* This request should only be made after the user has decided to interact with timeline to prevent any performance impacts
* to the underlying security solution views, as this query will always run when the timeline exists on the page.
*
* `hasTimelineBeenOpenedOnce` - We want to keep timeline loading times as fast as possible after the user
* has chosen to interact with timeline at least once, so we use this flag to prevent re-requesting of this fields data
* every time timeline is closed and re-opened after the first interaction.
*/
const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []);
const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId));
const [hasTimelineBeenOpenedOnce, setHasTimelineBeenOpenedOnce] = useState(false);
useEffect(() => {
if (!hasTimelineBeenOpenedOnce && show) {
setHasTimelineBeenOpenedOnce(true);
}
}, [hasTimelineBeenOpenedOnce, show]);
// END PERFORMANCE ONLY CODE BLOCK
return (
<TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}>
<TimelineResizableLayout
@ -351,7 +378,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
sidebarPanel={
<SidebarPanelFlexGroup gutterSize="none">
<EuiFlexItem className="sidebarContainer">
{dataView ? (
{dataView && hasTimelineBeenOpenedOnce ? (
<UnifiedFieldListSidebarContainer
ref={unifiedFieldListContainerRef}
showFieldList