[SIEM] [Detection Engine] Rule activity monitoring (#60816)

* backend rule monitoring with gap desc, last look back date, and time duration of search after and bulk create operations

* adds new properties to mocked request_response status saved object

* first pass at UI table

* migrate rule monitoring backend to work with refactor of rule executor, fix some formatting stuff on the frontend, update the mapping for gap to be a string instead of a float

* trying to write a test for rules statuses hook

* fixed hooks tests

* fixes merge conflicts from rebase with master

* add columns for indexing and query time lapse

* i18n

* i18n for tabs

* don't change the mappings in ml es_archives

* remove accidental commit of interval change for shell script detection engine rule

* removes inline object from prop

* fix merge conflicts

* backend changes from pr comments

* updates ui changes from pr feedback

* fix tests and add formatting for dates

* remove null from rulesStatuses initial state and replace with empty array
This commit is contained in:
Devin W. Hurley 2020-03-24 23:49:08 -04:00 committed by GitHub
parent 29a3f55985
commit 96852249e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 759 additions and 121 deletions

View file

@ -52,6 +52,36 @@ export const getRuleStatusById = async ({
last_success_at: 'mm/dd/yyyyTHH:MM:sssz',
last_failure_message: null,
last_success_message: 'it is a success',
gap: null,
bulk_create_time_durations: ['2235.01'],
search_after_time_durations: ['616.97'],
last_look_back_date: '2020-03-19T00:32:07.996Z',
},
failures: [],
},
});
export const getRulesStatusByIds = async ({
ids,
signal,
}: {
ids: string[];
signal: AbortSignal;
}): Promise<RuleStatusResponse> =>
Promise.resolve({
'12345678987654321': {
current_status: {
alert_id: 'alertId',
status_date: 'mm/dd/yyyyTHH:MM:sssz',
status: 'succeeded',
last_failure_at: null,
last_success_at: 'mm/dd/yyyyTHH:MM:sssz',
last_failure_message: null,
last_success_message: 'it is a success',
gap: null,
bulk_create_time_durations: ['2235.01'],
search_after_time_durations: ['616.97'],
last_look_back_date: '2020-03-19T00:32:07.996Z',
},
failures: [],
},

View file

@ -271,6 +271,32 @@ export const getRuleStatusById = async ({
signal,
});
/**
* Return rule statuses given list of alert ids
*
* @param ids array of string of Rule ID's (not rule_id)
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const getRulesStatusByIds = async ({
ids,
signal,
}: {
ids: string[];
signal: AbortSignal;
}): Promise<RuleStatusResponse> => {
const res = await KibanaServices.get().http.fetch<RuleStatusResponse>(
DETECTION_ENGINE_RULES_STATUS_URL,
{
method: 'GET',
query: { ids: JSON.stringify(ids) },
signal,
}
);
return res;
};
/**
* Fetch all unique Tags used by Rules
*

View file

@ -235,6 +235,10 @@ export interface RuleInfoStatus {
last_success_at: string | null;
last_failure_message: string | null;
last_success_message: string | null;
last_look_back_date: string | null | undefined;
gap: string | null | undefined;
bulk_create_time_durations: string[] | null | undefined;
search_after_time_durations: string[] | null | undefined;
}
export type RuleStatusResponse = Record<string, RuleStatus>;

View file

@ -4,13 +4,63 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useRuleStatus, ReturnRuleStatus } from './use_rule_status';
import { renderHook, act, cleanup } from '@testing-library/react-hooks';
import {
useRuleStatus,
ReturnRuleStatus,
useRulesStatuses,
ReturnRulesStatuses,
} from './use_rule_status';
import * as api from './api';
import { RuleType } from '../rules/types';
jest.mock('./api');
const testRule = {
created_at: 'mm/dd/yyyyTHH:MM:sssz',
created_by: 'mockUser',
description: 'some desc',
enabled: true,
false_positives: [],
filters: [],
from: 'now-360s',
id: '12345678987654321',
immutable: false,
index: [
'apm-*-transaction*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'packetbeat-*',
'winlogbeat-*',
],
interval: '5m',
language: 'kuery',
name: 'Test rule',
max_signals: 100,
query: "user.email: 'root@elastic.co'",
references: [],
risk_score: 75,
rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf',
severity: 'high',
tags: ['APM'],
threat: [],
to: 'now',
type: 'query' as RuleType,
updated_at: 'mm/dd/yyyyTHH:MM:sssz',
updated_by: 'mockUser',
};
describe('useRuleStatus', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
});
afterEach(async () => {
cleanup();
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnRuleStatus>(() =>
@ -39,6 +89,10 @@ describe('useRuleStatus', () => {
last_success_message: 'it is a success',
status: 'succeeded',
status_date: 'mm/dd/yyyyTHH:MM:sssz',
gap: null,
bulk_create_time_durations: ['2235.01'],
search_after_time_durations: ['616.97'],
last_look_back_date: '2020-03-19T00:32:07.996Z',
},
failures: [],
},
@ -62,4 +116,50 @@ describe('useRuleStatus', () => {
expect(spyOngetRuleStatusById).toHaveBeenCalledTimes(2);
});
});
test('init rules statuses', async () => {
const payload = [testRule];
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnRulesStatuses>(() =>
useRulesStatuses(payload)
);
await waitForNextUpdate();
expect(result.current).toEqual({ loading: false, rulesStatuses: [] });
});
});
test('fetch rules statuses', async () => {
const payload = [testRule];
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnRulesStatuses>(() =>
useRulesStatuses(payload)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
loading: false,
rulesStatuses: [
{
current_status: {
alert_id: 'alertId',
bulk_create_time_durations: ['2235.01'],
gap: null,
last_failure_at: null,
last_failure_message: null,
last_look_back_date: '2020-03-19T00:32:07.996Z',
last_success_at: 'mm/dd/yyyyTHH:MM:sssz',
last_success_message: 'it is a success',
search_after_time_durations: ['616.97'],
status: 'succeeded',
status_date: 'mm/dd/yyyyTHH:MM:sssz',
},
failures: [],
id: '12345678987654321',
activate: true,
name: 'Test rule',
},
],
});
});
});
});

View file

@ -7,12 +7,17 @@
import { useEffect, useRef, useState } from 'react';
import { errorToToaster, useStateToaster } from '../../../components/toasters';
import { getRuleStatusById } from './api';
import { RuleStatusRowItemType } from '../../../pages/detection_engine/rules/all/columns';
import { getRuleStatusById, getRulesStatusByIds } from './api';
import * as i18n from './translations';
import { RuleStatus } from './types';
import { RuleStatus, Rules } from './types';
type Func = (ruleId: string) => void;
export type ReturnRuleStatus = [boolean, RuleStatus | null, Func | null];
export interface ReturnRulesStatuses {
loading: boolean;
rulesStatuses: RuleStatusRowItemType[] | null;
}
/**
* Hook for using to get a Rule from the Detection Engine API
@ -33,7 +38,6 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus =
const fetchData = async (idToFetch: string) => {
try {
setLoading(true);
const ruleStatusResponse = await getRuleStatusById({
id: idToFetch,
signal: abortCtrl.signal,
@ -64,3 +68,58 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus =
return [loading, ruleStatus, fetchRuleStatus.current];
};
/**
* Hook for using to get all the statuses for all given rule ids
*
* @param ids desired Rule ID's (not rule_id)
*
*/
export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => {
const [rulesStatuses, setRuleStatuses] = useState<RuleStatusRowItemType[]>([]);
const [loading, setLoading] = useState(false);
const [, dispatchToaster] = useStateToaster();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchData = async (ids: string[]) => {
try {
setLoading(true);
const ruleStatusesResponse = await getRulesStatusByIds({
ids,
signal: abortCtrl.signal,
});
if (isSubscribed) {
setRuleStatuses(
rules.map(rule => ({
id: rule.id,
activate: rule.enabled,
name: rule.name,
...ruleStatusesResponse[rule.id],
}))
);
}
} catch (error) {
if (isSubscribed) {
setRuleStatuses([]);
errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster });
}
}
if (isSubscribed) {
setLoading(false);
}
};
if (rules != null && rules.length > 0) {
fetchData(rules.map(r => r.id));
}
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [rules]);
return { loading, rulesStatuses };
};

View file

@ -14,10 +14,11 @@ import {
EuiText,
EuiHealth,
} from '@elastic/eui';
import { FormattedRelative } from '@kbn/i18n/react';
import * as H from 'history';
import React, { Dispatch } from 'react';
import { Rule } from '../../../../containers/detection_engine/rules';
import { Rule, RuleStatus } from '../../../../containers/detection_engine/rules';
import { getEmptyTagValue } from '../../../../components/empty_value';
import { FormattedDate } from '../../../../components/formatted_date';
import { getRuleDetailsUrl } from '../../../../components/link_to/redirect_to_detection_engine';
@ -34,6 +35,7 @@ import {
exportRulesAction,
} from './actions';
import { Action } from './reducer';
import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip';
export const getActions = (
dispatch: React.Dispatch<Action>,
@ -75,7 +77,12 @@ export const getActions = (
},
];
export type RuleStatusRowItemType = RuleStatus & {
name: string;
id: string;
};
type RulesColumns = EuiBasicTableColumn<Rule> | EuiTableActionsColumnType<Rule>;
type RulesStatusesColumns = EuiBasicTableColumn<RuleStatusRowItemType>;
interface GetColumns {
dispatch: React.Dispatch<Action>;
@ -132,7 +139,9 @@ export const getColumns = ({
return value == null ? (
getEmptyTagValue()
) : (
<FormattedDate value={value} fieldName={i18n.COLUMN_LAST_COMPLETE_RUN} />
<LocalizedDateTooltip fieldName={i18n.COLUMN_LAST_COMPLETE_RUN} date={new Date(value)}>
<FormattedRelative value={value} />
</LocalizedDateTooltip>
);
},
sortable: true,
@ -196,3 +205,114 @@ export const getColumns = ({
return hasNoPermissions ? cols : [...cols, ...actions];
};
export const getMonitoringColumns = (): RulesStatusesColumns[] => {
const cols: RulesStatusesColumns[] = [
{
field: 'name',
name: i18n.COLUMN_RULE,
render: (value: RuleStatus['current_status']['status'], item: RuleStatusRowItemType) => {
return (
<EuiLink data-test-subj="ruleName" href={getRuleDetailsUrl(item.id)}>
{value}
</EuiLink>
);
},
truncateText: true,
width: '24%',
},
{
field: 'current_status.bulk_create_time_durations',
name: i18n.COLUMN_INDEXING_TIMES,
render: (value: RuleStatus['current_status']['bulk_create_time_durations']) => (
<EuiText data-test-subj="bulk_create_time_durations" size="s">
{value != null && value.length > 0
? Math.max(...value?.map(item => Number.parseFloat(item)))
: null}
</EuiText>
),
truncateText: true,
width: '14%',
},
{
field: 'current_status.search_after_time_durations',
name: i18n.COLUMN_QUERY_TIMES,
render: (value: RuleStatus['current_status']['search_after_time_durations']) => (
<EuiText data-test-subj="search_after_time_durations" size="s">
{value != null && value.length > 0
? Math.max(...value?.map(item => Number.parseFloat(item)))
: null}
</EuiText>
),
truncateText: true,
width: '14%',
},
{
field: 'current_status.gap',
name: i18n.COLUMN_GAP,
render: (value: RuleStatus['current_status']['gap']) => (
<EuiText data-test-subj="gap" size="s">
{value}
</EuiText>
),
truncateText: true,
width: '14%',
},
{
field: 'current_status.last_look_back_date',
name: i18n.COLUMN_LAST_LOOKBACK_DATE,
render: (value: RuleStatus['current_status']['last_look_back_date']) => {
return value == null ? (
getEmptyTagValue()
) : (
<FormattedDate value={value} fieldName={'last look back date'} />
);
},
truncateText: true,
width: '16%',
},
{
field: 'current_status.status_date',
name: i18n.COLUMN_LAST_COMPLETE_RUN,
render: (value: RuleStatus['current_status']['status_date']) => {
return value == null ? (
getEmptyTagValue()
) : (
<LocalizedDateTooltip fieldName={i18n.COLUMN_LAST_COMPLETE_RUN} date={new Date(value)}>
<FormattedRelative value={value} />
</LocalizedDateTooltip>
);
},
sortable: true,
truncateText: true,
width: '20%',
},
{
field: 'current_status.status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: RuleStatus['current_status']['status']) => {
return (
<>
<EuiHealth color={getStatusColor(value ?? null)}>
{value ?? getEmptyTagValue()}
</EuiHealth>
</>
);
},
width: '16%',
truncateText: true,
},
{
field: 'activate',
name: i18n.COLUMN_ACTIVATE,
render: (value: Rule['enabled']) => (
<EuiText data-test-subj="search_after_time_durations" size="s">
{value ? i18n.ACTIVE : i18n.INACTIVE}
</EuiText>
),
width: '95px',
},
];
return cols;
};

View file

@ -4,20 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBasicTable,
EuiContextMenuPanel,
EuiEmptyPrompt,
EuiLoadingContent,
EuiSpacer,
} from '@elastic/eui';
import { EuiBasicTable, EuiContextMenuPanel, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import uuid from 'uuid';
import {
useRules,
useRulesStatuses,
CreatePreBuiltRules,
FilterOptions,
Rule,
@ -37,20 +31,16 @@ import { Loader } from '../../../../components/loader';
import { Panel } from '../../../../components/panel';
import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt';
import { GenericDownloader } from '../../../../components/generic_downloader';
import { AllRulesTables } from '../components/all_rules_tables';
import { getPrePackagedRuleStatus } from '../helpers';
import * as i18n from '../translations';
import { EuiBasicTableOnChange } from '../types';
import { getBatchItems } from './batch_actions';
import { getColumns } from './columns';
import { getColumns, getMonitoringColumns } from './columns';
import { showRulesTable } from './helpers';
import { allRulesReducer, State } from './reducer';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way
// after few hours of fight with typescript !!!! I lost :(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any;
const initialState: State = {
exportRuleIds: [],
filterOptions: {
@ -117,6 +107,7 @@ export const AllRules = React.memo<AllRulesProps>(
},
dispatch,
] = useReducer(allRulesReducer(tableRef), initialState);
const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules);
const history = useHistory();
const [, dispatchToaster] = useStateToaster();
@ -135,6 +126,13 @@ export const AllRules = React.memo<AllRulesProps>(
dispatchRulesInReducer: setRules,
});
const sorting = useMemo(
() => ({
sort: { field: 'enabled', direction: filterOptions.sortOrder },
}),
[filterOptions.sortOrder]
);
const prePackagedRuleStatus = getPrePackagedRuleStatus(
rulesInstalled,
rulesNotInstalled,
@ -158,6 +156,16 @@ export const AllRules = React.memo<AllRulesProps>(
[dispatch, dispatchToaster, loadingRuleIds, reFetchRulesData, rules, selectedRuleIds]
);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}),
[pagination]
);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
dispatch({
@ -172,7 +180,7 @@ export const AllRules = React.memo<AllRulesProps>(
[dispatch]
);
const columns = useMemo(() => {
const rulesColumns = useMemo(() => {
return getColumns({
dispatch,
dispatchToaster,
@ -187,6 +195,8 @@ export const AllRules = React.memo<AllRulesProps>(
});
}, [dispatch, dispatchToaster, history, loadingRuleIds, loadingRulesAction, reFetchRulesData]);
const monitoringColumns = useMemo(() => getMonitoringColumns(), []);
useEffect(() => {
if (reFetchRulesData != null) {
setRefreshRulesData(reFetchRulesData);
@ -194,10 +204,10 @@ export const AllRules = React.memo<AllRulesProps>(
}, [reFetchRulesData, setRefreshRulesData]);
useEffect(() => {
if (initLoading && !loading && !isLoadingRules) {
if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) {
setInitLoading(false);
}
}, [initLoading, loading, isLoadingRules]);
}, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null && reFetchRulesData != null) {
@ -225,12 +235,6 @@ export const AllRules = React.memo<AllRulesProps>(
});
}, []);
const emptyPrompt = useMemo(() => {
return (
<EuiEmptyPrompt title={<h3>{i18n.NO_RULES}</h3>} titleSize="xs" body={i18n.NO_RULES_BODY} />
);
}, []);
const isLoadingAnActionOnRule = useMemo(() => {
if (
loadingRuleIds.length > 0 &&
@ -264,7 +268,7 @@ export const AllRules = React.memo<AllRulesProps>(
/>
<EuiSpacer />
<Panel loading={loading || isLoadingRules}>
<Panel loading={loading || isLoadingRules || isLoadingRulesStatuses}>
<>
<HeaderSection split title={i18n.ALL_RULES}>
<RulesTableFilters
@ -274,12 +278,14 @@ export const AllRules = React.memo<AllRulesProps>(
/>
</HeaderSection>
{(loading || isLoadingRules || isLoadingAnActionOnRule) && !initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) &&
!initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{rulesCustomInstalled != null &&
rulesCustomInstalled === 0 &&
prePackagedRuleStatus === 'ruleNotInstalled' && (
prePackagedRuleStatus === 'ruleNotInstalled' &&
!initLoading && (
<PrePackagedRulesPrompt
createPrePackagedRules={handleCreatePrePackagedRules}
loading={loadingCreatePrePackagedRules}
@ -318,23 +324,17 @@ export const AllRules = React.memo<AllRulesProps>(
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
<MyEuiBasicTable
data-test-subj="rules-table"
columns={columns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={rules ?? []}
noItemsMessage={emptyPrompt}
onChange={tableOnChangeCallback}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}}
ref={tableRef}
sorting={{ sort: { field: 'enabled', direction: filterOptions.sortOrder } }}
selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps}
<AllRulesTables
euiBasicTableSelectionProps={euiBasicTableSelectionProps}
hasNoPermissions={hasNoPermissions}
monitoringColumns={monitoringColumns}
paginationMemo={paginationMemo}
rules={rules}
rulesColumns={rulesColumns}
rulesStatuses={rulesStatuses}
sorting={sorting}
tableOnChangeCallback={tableOnChangeCallback}
tableRef={tableRef}
/>
</>
)}

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBasicTable, EuiTab, EuiTabs, EuiEmptyPrompt } from '@elastic/eui';
import React, { useMemo, memo, useState } from 'react';
import styled from 'styled-components';
import * as i18n from '../../translations';
import { RuleStatusRowItemType } from '../../../../../pages/detection_engine/rules/all/columns';
import { Rules } from '../../../../../containers/detection_engine/rules';
// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way
// after few hours of fight with typescript !!!! I lost :(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any;
interface AllRulesTablesProps {
euiBasicTableSelectionProps: unknown;
hasNoPermissions: boolean;
monitoringColumns: unknown;
paginationMemo: unknown;
rules: Rules;
rulesColumns: unknown;
rulesStatuses: RuleStatusRowItemType[] | null;
sorting: unknown;
tableOnChangeCallback: unknown;
tableRef?: unknown;
}
enum AllRulesTabs {
rules = 'rules',
monitoring = 'monitoring',
}
const allRulesTabs = [
{
id: AllRulesTabs.rules,
name: i18n.RULES_TAB,
disabled: false,
},
{
id: AllRulesTabs.monitoring,
name: i18n.MONITORING_TAB,
disabled: false,
},
];
const AllRulesTablesComponent: React.FC<AllRulesTablesProps> = ({
euiBasicTableSelectionProps,
hasNoPermissions,
monitoringColumns,
paginationMemo,
rules,
rulesColumns,
rulesStatuses,
sorting,
tableOnChangeCallback,
tableRef,
}) => {
const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules);
const emptyPrompt = useMemo(() => {
return (
<EuiEmptyPrompt title={<h3>{i18n.NO_RULES}</h3>} titleSize="xs" body={i18n.NO_RULES_BODY} />
);
}, []);
const tabs = useMemo(
() => (
<EuiTabs>
{allRulesTabs.map(tab => (
<EuiTab
onClick={() => setAllRulesTab(tab.id)}
isSelected={tab.id === allRulesTab}
disabled={tab.disabled}
key={tab.id}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
),
[allRulesTabs, allRulesTab, setAllRulesTab]
);
return (
<>
{tabs}
{allRulesTab === AllRulesTabs.rules && (
<MyEuiBasicTable
data-test-subj="rules-table"
columns={rulesColumns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={rules ?? []}
noItemsMessage={emptyPrompt}
onChange={tableOnChangeCallback}
pagination={paginationMemo}
ref={tableRef}
{...sorting}
selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps}
/>
)}
{allRulesTab === AllRulesTabs.monitoring && (
<MyEuiBasicTable
data-test-subj="monitoring-table"
columns={monitoringColumns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={rulesStatuses}
noItemsMessage={emptyPrompt}
onChange={tableOnChangeCallback}
pagination={paginationMemo}
{...sorting}
/>
)}
</>
);
};
export const AllRulesTables = memo(AllRulesTablesComponent);

View file

@ -44,6 +44,20 @@ export const BATCH_ACTIONS = i18n.translate(
}
);
export const ACTIVE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.activeRuleDescription',
{
defaultMessage: 'active',
}
);
export const INACTIVE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.inactiveRuleDescription',
{
defaultMessage: 'inactive',
}
);
export const BATCH_ACTION_ACTIVATE_SELECTED = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedTitle',
{
@ -255,6 +269,42 @@ export const COLUMN_ACTIVATE = i18n.translate(
}
);
export const COLUMN_INDEXING_TIMES = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.columns.indexingTimes',
{
defaultMessage: 'Indexing Time (ms)',
}
);
export const COLUMN_QUERY_TIMES = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.columns.queryTimes',
{
defaultMessage: 'Query Time (ms)',
}
);
export const COLUMN_GAP = i18n.translate('xpack.siem.detectionEngine.rules.allRules.columns.gap', {
defaultMessage: 'Gap (if any)',
});
export const COLUMN_LAST_LOOKBACK_DATE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.columns.lastLookBackDate',
{
defaultMessage: 'Last Look-Back Date',
}
);
export const RULES_TAB = i18n.translate('xpack.siem.detectionEngine.rules.allRules.tabs.rules', {
defaultMessage: 'Rules',
});
export const MONITORING_TAB = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.tabs.monitoring',
{
defaultMessage: 'Monitoring',
}
);
export const CUSTOM_RULES = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.filters.customRulesTitle',
{

View file

@ -592,6 +592,10 @@ export const getFindResultStatus = (): SavedObjectsFindResponse<IRuleSavedAttrib
lastSuccessAt: '2020-02-18T15:26:49.783Z',
lastFailureMessage: null,
lastSuccessMessage: 'succeeded',
lastLookBackDate: new Date('2020-02-18T15:14:58.806Z').toISOString(),
gap: '500.32',
searchAfterTimeDurations: ['200.00'],
bulkCreateTimeDurations: ['800.43'],
},
references: [],
updated_at: '2020-02-18T15:26:51.333Z',
@ -609,6 +613,10 @@ export const getFindResultStatus = (): SavedObjectsFindResponse<IRuleSavedAttrib
lastFailureMessage:
'Signal rule name: "Query with a rule id Number 1", id: "1ea5a820-4da1-4e82-92a1-2b43a7bece08", rule_id: "query-rule-id-1" has a time gap of 5 days (412682928ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.',
lastSuccessMessage: 'succeeded',
lastLookBackDate: new Date('2020-02-18T15:14:58.806Z').toISOString(),
gap: '500.32',
searchAfterTimeDurations: ['200.00'],
bulkCreateTimeDurations: ['800.43'],
},
references: [],
updated_at: '2020-02-18T15:15:58.860Z',

View file

@ -4,14 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ElasticsearchMappingOf } from '../../../utils/typed_elasticsearch_mappings';
import { IRuleStatusAttributes } from './types';
export const ruleStatusSavedObjectType = 'siem-detection-engine-rule-status';
export const ruleStatusSavedObjectMappings: {
[ruleStatusSavedObjectType]: ElasticsearchMappingOf<IRuleStatusAttributes>;
} = {
export const ruleStatusSavedObjectMappings = {
[ruleStatusSavedObjectType]: {
properties: {
alertId: {
@ -35,6 +30,18 @@ export const ruleStatusSavedObjectMappings: {
lastSuccessMessage: {
type: 'text',
},
lastLookBackDate: {
type: 'date',
},
gap: {
type: 'text',
},
bulkCreateTimeDurations: {
type: 'float',
},
searchAfterTimeDurations: {
type: 'float',
},
},
},
};

View file

@ -60,6 +60,10 @@ export interface IRuleStatusAttributes extends Record<string, any> {
lastSuccessAt: string | null | undefined;
lastSuccessMessage: string | null | undefined;
status: RuleStatusString | null | undefined;
lastLookBackDate: string | null | undefined;
gap: string | null | undefined;
bulkCreateTimeDurations: string[] | null | undefined;
searchAfterTimeDurations: string[] | null | undefined;
}
export interface RuleStatusResponse {

View file

@ -11,7 +11,7 @@ import { Logger } from '../../../../../../../../src/core/server';
import { AlertServices } from '../../../../../../../plugins/alerting/server';
import { RuleAlertAction } from '../../../../common/detection_engine/types';
import { RuleTypeParams } from '../types';
import { singleBulkCreate } from './single_bulk_create';
import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create';
import { AnomalyResults, Anomaly } from '../../machine_learning';
interface BulkCreateMlSignalsParams {
@ -75,7 +75,9 @@ const transformAnomalyResultsToEcs = (results: AnomalyResults): SearchResponse<E
};
};
export const bulkCreateMlSignals = async (params: BulkCreateMlSignalsParams) => {
export const bulkCreateMlSignals = async (
params: BulkCreateMlSignalsParams
): Promise<SingleBulkCreateResponse> => {
const anomalyResults = params.someResult;
const ecsResults = transformAnomalyResultsToEcs(anomalyResults);

View file

@ -36,6 +36,10 @@ export const getCurrentStatusSavedObject = async ({
lastSuccessAt: null,
lastFailureMessage: null,
lastSuccessMessage: null,
gap: null,
bulkCreateTimeDurations: [],
searchAfterTimeDurations: [],
lastLookBackDate: null,
});
return currentStatusSavedObject;
} else {

View file

@ -34,7 +34,7 @@ describe('searchAfterAndBulkCreate', () => {
test('if successful with empty search results', async () => {
const sampleParams = sampleRuleAlertParams();
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: sampleEmptyDocSearchResults(),
ruleParams: sampleParams,
services: mockService,
@ -56,7 +56,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: null,
});
expect(mockService.callCluster).toHaveBeenCalledTimes(0);
expect(result).toEqual(true);
expect(success).toEqual(true);
});
test('if successful iteration of while loop with maxDocs', async () => {
@ -92,7 +92,7 @@ describe('searchAfterAndBulkCreate', () => {
},
],
});
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(3, 1, someGuids.slice(6, 9)),
ruleParams: sampleParams,
services: mockService,
@ -114,14 +114,14 @@ describe('searchAfterAndBulkCreate', () => {
throttle: null,
});
expect(mockService.callCluster).toHaveBeenCalledTimes(5);
expect(result).toEqual(true);
expect(success).toEqual(true);
});
test('if unsuccessful first bulk create', async () => {
const someGuids = Array.from({ length: 4 }).map(x => uuid.v4());
const sampleParams = sampleRuleAlertParams(10);
mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult);
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@ -143,7 +143,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: null,
});
expect(mockLogger.error).toHaveBeenCalled();
expect(result).toEqual(false);
expect(success).toEqual(false);
});
test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => {
@ -157,7 +157,7 @@ describe('searchAfterAndBulkCreate', () => {
},
],
});
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: sampleDocSearchResultsNoSortId(),
ruleParams: sampleParams,
services: mockService,
@ -179,7 +179,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: null,
});
expect(mockLogger.error).toHaveBeenCalled();
expect(result).toEqual(false);
expect(success).toEqual(false);
});
test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => {
@ -193,7 +193,7 @@ describe('searchAfterAndBulkCreate', () => {
},
],
});
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: sampleDocSearchResultsNoSortIdNoHits(),
ruleParams: sampleParams,
services: mockService,
@ -214,7 +214,7 @@ describe('searchAfterAndBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(result).toEqual(true);
expect(success).toEqual(true);
});
test('if successful iteration of while loop with maxDocs and search after returns results with no sort ids', async () => {
@ -231,7 +231,7 @@ describe('searchAfterAndBulkCreate', () => {
],
})
.mockReturnValueOnce(sampleDocSearchResultsNoSortId());
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@ -252,7 +252,7 @@ describe('searchAfterAndBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(result).toEqual(true);
expect(success).toEqual(true);
});
test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => {
@ -269,7 +269,7 @@ describe('searchAfterAndBulkCreate', () => {
],
})
.mockReturnValueOnce(sampleEmptyDocSearchResults());
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@ -290,7 +290,7 @@ describe('searchAfterAndBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(result).toEqual(true);
expect(success).toEqual(true);
});
test('if returns false when singleSearchAfter throws an exception', async () => {
@ -309,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => {
.mockImplementation(() => {
throw Error('Fake Error');
});
const result = await searchAfterAndBulkCreate({
const { success } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@ -330,6 +330,6 @@ describe('searchAfterAndBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(result).toEqual(false);
expect(success).toEqual(false);
});
});

View file

@ -34,6 +34,13 @@ interface SearchAfterAndBulkCreateParams {
throttle: string | null;
}
export interface SearchAfterAndBulkCreateReturnType {
success: boolean;
searchAfterTimes: string[];
bulkCreateTimes: string[];
lastLookBackDate: Date | null | undefined;
}
// search_after through documents and re-index using bulk endpoint.
export const searchAfterAndBulkCreate = async ({
someResult,
@ -55,13 +62,20 @@ export const searchAfterAndBulkCreate = async ({
pageSize,
tags,
throttle,
}: SearchAfterAndBulkCreateParams): Promise<boolean> => {
}: SearchAfterAndBulkCreateParams): Promise<SearchAfterAndBulkCreateReturnType> => {
const toReturn: SearchAfterAndBulkCreateReturnType = {
success: false,
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
};
if (someResult.hits.hits.length === 0) {
return true;
toReturn.success = true;
return toReturn;
}
logger.debug('[+] starting bulk insertion');
await singleBulkCreate({
const { bulkCreateDuration } = await singleBulkCreate({
someResult,
ruleParams,
services,
@ -79,6 +93,13 @@ export const searchAfterAndBulkCreate = async ({
tags,
throttle,
});
toReturn.lastLookBackDate =
someResult.hits.hits.length > 0
? new Date(someResult.hits.hits[someResult.hits.hits.length - 1]?._source['@timestamp'])
: null;
if (bulkCreateDuration) {
toReturn.bulkCreateTimes.push(bulkCreateDuration);
}
const totalHits =
typeof someResult.hits.total === 'number' ? someResult.hits.total : someResult.hits.total.value;
// maxTotalHitsSize represents the total number of docs to
@ -94,9 +115,11 @@ export const searchAfterAndBulkCreate = async ({
let sortIds = someResult.hits.hits[0].sort;
if (sortIds == null && totalHits > 0) {
logger.error('sortIds was empty on first search but expected more');
return false;
toReturn.success = false;
return toReturn;
} else if (sortIds == null && totalHits === 0) {
return true;
toReturn.success = true;
return toReturn;
}
let sortId;
if (sortIds != null) {
@ -105,7 +128,10 @@ export const searchAfterAndBulkCreate = async ({
while (hitsSize < maxTotalHitsSize && hitsSize !== 0) {
try {
logger.debug(`sortIds: ${sortIds}`);
const searchAfterResult: SignalSearchResponse = await singleSearchAfter({
const {
searchResult,
searchDuration,
}: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter({
searchAfterSortId: sortId,
index: inputIndexPattern,
from: ruleParams.from,
@ -115,20 +141,23 @@ export const searchAfterAndBulkCreate = async ({
filter,
pageSize, // maximum number of docs to receive per search result.
});
if (searchAfterResult.hits.hits.length === 0) {
return true;
toReturn.searchAfterTimes.push(searchDuration);
if (searchResult.hits.hits.length === 0) {
toReturn.success = true;
return toReturn;
}
hitsSize += searchAfterResult.hits.hits.length;
hitsSize += searchResult.hits.hits.length;
logger.debug(`size adjusted: ${hitsSize}`);
sortIds = searchAfterResult.hits.hits[0].sort;
sortIds = searchResult.hits.hits[0].sort;
if (sortIds == null) {
logger.debug('sortIds was empty on search');
return true; // no more search results
toReturn.success = true;
return toReturn; // no more search results
}
sortId = sortIds[0];
logger.debug('next bulk index');
await singleBulkCreate({
someResult: searchAfterResult,
const { bulkCreateDuration: bulkDuration } = await singleBulkCreate({
someResult: searchResult,
ruleParams,
services,
logger,
@ -146,11 +175,16 @@ export const searchAfterAndBulkCreate = async ({
throttle,
});
logger.debug('finished next bulk index');
if (bulkDuration) {
toReturn.bulkCreateTimes.push(bulkDuration);
}
} catch (exc) {
logger.error(`[-] search_after and bulk threw an error ${exc}`);
return false;
toReturn.success = false;
return toReturn;
}
}
logger.debug(`[+] completed bulk index of ${maxTotalHitsSize}`);
return true;
toReturn.success = true;
return toReturn;
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { performance } from 'perf_hooks';
import { Logger } from 'src/core/server';
import {
SIGNALS_ID,
@ -13,10 +14,13 @@ import {
import { buildEventsSearchQuery } from './build_events_query';
import { getInputIndex } from './get_input_output_index';
import { searchAfterAndBulkCreate } from './search_after_bulk_create';
import {
searchAfterAndBulkCreate,
SearchAfterAndBulkCreateReturnType,
} from './search_after_bulk_create';
import { getFilter } from './get_filter';
import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types';
import { getGapBetweenRuns } from './utils';
import { getGapBetweenRuns, makeFloatString } from './utils';
import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object';
import { signalParamsSchema } from './signal_params_schema';
import { siemRuleActionGroups } from './siem_rule_action_groups';
@ -92,7 +96,6 @@ export const signalRulesAlertType = ({
const updatedAt = savedObject.updated_at ?? '';
const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to });
await writeGapErrorToSavedObject({
alertId,
logger,
@ -105,7 +108,12 @@ export const signalRulesAlertType = ({
});
const searchAfterSize = Math.min(params.maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE);
let creationSucceeded = false;
let creationSucceeded: SearchAfterAndBulkCreateReturnType = {
success: false,
bulkCreateTimes: [],
searchAfterTimes: [],
lastLookBackDate: null,
};
try {
if (type === 'machine_learning') {
@ -130,7 +138,7 @@ export const signalRulesAlertType = ({
);
}
creationSucceeded = await bulkCreateMlSignals({
const { success, bulkCreateDuration } = await bulkCreateMlSignals({
actions,
throttle,
someResult: anomalyResults,
@ -148,6 +156,10 @@ export const signalRulesAlertType = ({
enabled,
tags,
});
creationSucceeded.success = success;
if (bulkCreateDuration) {
creationSucceeded.bulkCreateTimes.push(bulkCreateDuration);
}
} else {
const inputIndex = await getInputIndex(services, version, index);
const esFilter = await getFilter({
@ -175,7 +187,10 @@ export const signalRulesAlertType = ({
logger.debug(
`[+] Initial search call of signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"`
);
const start = performance.now();
const noReIndexResult = await services.callCluster('search', noReIndex);
const end = performance.now();
if (noReIndexResult.hits.total.value !== 0) {
logger.info(
`Found ${
@ -207,9 +222,10 @@ export const signalRulesAlertType = ({
tags,
throttle,
});
creationSucceeded.searchAfterTimes.push(makeFloatString(end - start));
}
if (creationSucceeded) {
if (creationSucceeded.success) {
if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) {
const notificationRuleParams = {
...ruleParams,
@ -242,11 +258,14 @@ export const signalRulesAlertType = ({
}
logger.debug(
`Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"`
`Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"`
);
await writeCurrentStatusSucceeded({
services,
currentStatusSavedObject,
bulkCreateTimes: creationSucceeded.bulkCreateTimes,
searchAfterTimes: creationSucceeded.searchAfterTimes,
lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null,
});
} else {
await writeSignalRuleExceptionToSavedObject({
@ -254,22 +273,28 @@ export const signalRulesAlertType = ({
alertId,
currentStatusSavedObject,
logger,
message: `Bulk Indexing signals failed. Check logs for further details Rule name: "${name}" id: "${alertId}" rule_id: "${ruleId}" output_index: "${outputIndex}"`,
message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`,
services,
ruleStatusSavedObjects,
ruleId: ruleId ?? '(unknown rule id)',
bulkCreateTimes: creationSucceeded.bulkCreateTimes,
searchAfterTimes: creationSucceeded.searchAfterTimes,
lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null,
});
}
} catch (error) {
} catch (err) {
await writeSignalRuleExceptionToSavedObject({
name,
alertId,
currentStatusSavedObject,
logger,
message: error?.message ?? '(no error message given)',
message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`,
services,
ruleStatusSavedObjects,
ruleId: ruleId ?? '(unknown rule id)',
bulkCreateTimes: creationSucceeded.bulkCreateTimes,
searchAfterTimes: creationSucceeded.searchAfterTimes,
lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null,
});
}
},

View file

@ -144,7 +144,7 @@ describe('singleBulkCreate', () => {
},
],
});
const successfulsingleBulkCreate = await singleBulkCreate({
const { success } = await singleBulkCreate({
someResult: sampleDocSearchResultsNoSortId(),
ruleParams: sampleParams,
services: mockService,
@ -162,7 +162,7 @@ describe('singleBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(successfulsingleBulkCreate).toEqual(true);
expect(success).toEqual(true);
});
test('create successful bulk create with docs with no versioning', async () => {
@ -176,7 +176,7 @@ describe('singleBulkCreate', () => {
},
],
});
const successfulsingleBulkCreate = await singleBulkCreate({
const { success } = await singleBulkCreate({
someResult: sampleDocSearchResultsNoSortIdNoVersion(),
ruleParams: sampleParams,
services: mockService,
@ -194,13 +194,13 @@ describe('singleBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(successfulsingleBulkCreate).toEqual(true);
expect(success).toEqual(true);
});
test('create unsuccessful bulk create due to empty search results', async () => {
const sampleParams = sampleRuleAlertParams();
mockService.callCluster.mockReturnValue(false);
const successfulsingleBulkCreate = await singleBulkCreate({
const { success } = await singleBulkCreate({
someResult: sampleEmptyDocSearchResults(),
ruleParams: sampleParams,
services: mockService,
@ -218,14 +218,14 @@ describe('singleBulkCreate', () => {
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: null,
});
expect(successfulsingleBulkCreate).toEqual(true);
expect(success).toEqual(true);
});
test('create successful bulk create when bulk create has duplicate errors', async () => {
const sampleParams = sampleRuleAlertParams();
const sampleSearchResult = sampleDocSearchResultsNoSortId;
mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult);
const successfulsingleBulkCreate = await singleBulkCreate({
const { success } = await singleBulkCreate({
someResult: sampleSearchResult(),
ruleParams: sampleParams,
services: mockService,
@ -245,14 +245,14 @@ describe('singleBulkCreate', () => {
});
expect(mockLogger.error).not.toHaveBeenCalled();
expect(successfulsingleBulkCreate).toEqual(true);
expect(success).toEqual(true);
});
test('create successful bulk create when bulk create has multiple error statuses', async () => {
const sampleParams = sampleRuleAlertParams();
const sampleSearchResult = sampleDocSearchResultsNoSortId;
mockService.callCluster.mockReturnValue(sampleBulkCreateErrorResult);
const successfulsingleBulkCreate = await singleBulkCreate({
const { success } = await singleBulkCreate({
someResult: sampleSearchResult(),
ruleParams: sampleParams,
services: mockService,
@ -272,7 +272,7 @@ describe('singleBulkCreate', () => {
});
expect(mockLogger.error).toHaveBeenCalled();
expect(successfulsingleBulkCreate).toEqual(true);
expect(success).toEqual(true);
});
test('filter duplicate rules will return an empty array given an empty array', () => {

View file

@ -10,7 +10,7 @@ import { AlertServices } from '../../../../../../../plugins/alerting/server';
import { SignalSearchResponse, BulkResponse } from './types';
import { RuleAlertAction } from '../../../../common/detection_engine/types';
import { RuleTypeParams } from '../types';
import { generateId } from './utils';
import { generateId, makeFloatString } from './utils';
import { buildBulkBody } from './build_bulk_body';
import { Logger } from '../../../../../../../../src/core/server';
@ -55,6 +55,11 @@ export const filterDuplicateRules = (
});
};
export interface SingleBulkCreateResponse {
success: boolean;
bulkCreateDuration?: string;
}
// Bulk Index documents.
export const singleBulkCreate = async ({
someResult,
@ -73,11 +78,10 @@ export const singleBulkCreate = async ({
enabled,
tags,
throttle,
}: SingleBulkCreateParams): Promise<boolean> => {
}: SingleBulkCreateParams): Promise<SingleBulkCreateResponse> => {
someResult.hits.hits = filterDuplicateRules(id, someResult);
if (someResult.hits.hits.length === 0) {
return true;
return { success: true };
}
// index documents after creating an ID based on the
// source documents' originating index, and the original
@ -123,7 +127,7 @@ export const singleBulkCreate = async ({
body: bulkBody,
});
const end = performance.now();
logger.debug(`individual bulk process time took: ${Number(end - start).toFixed(2)} milliseconds`);
logger.debug(`individual bulk process time took: ${makeFloatString(end - start)} milliseconds`);
logger.debug(`took property says bulk took: ${response.took} milliseconds`);
if (response.errors) {
@ -141,5 +145,5 @@ export const singleBulkCreate = async ({
);
}
}
return true;
return { success: true, bulkCreateDuration: makeFloatString(end - start) };
};

View file

@ -42,7 +42,7 @@ describe('singleSearchAfter', () => {
test('if singleSearchAfter works with a given sort id', async () => {
const searchAfterSortId = '1234567891111';
mockService.callCluster.mockReturnValue(sampleDocSearchResultsWithSortId);
const searchAfterResult = await singleSearchAfter({
const { searchResult } = await singleSearchAfter({
searchAfterSortId,
index: [],
from: 'now-360s',
@ -52,7 +52,7 @@ describe('singleSearchAfter', () => {
pageSize: 1,
filter: undefined,
});
expect(searchAfterResult).toEqual(sampleDocSearchResultsWithSortId);
expect(searchResult).toEqual(sampleDocSearchResultsWithSortId);
});
test('if singleSearchAfter throws error', async () => {
const searchAfterSortId = '1234567891111';

View file

@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { performance } from 'perf_hooks';
import { AlertServices } from '../../../../../../../plugins/alerting/server';
import { Logger } from '../../../../../../../../src/core/server';
import { SignalSearchResponse } from './types';
import { buildEventsSearchQuery } from './build_events_query';
import { makeFloatString } from './utils';
interface SingleSearchAfterParams {
searchAfterSortId: string | undefined;
@ -30,7 +32,10 @@ export const singleSearchAfter = async ({
filter,
logger,
pageSize,
}: SingleSearchAfterParams): Promise<SignalSearchResponse> => {
}: SingleSearchAfterParams): Promise<{
searchResult: SignalSearchResponse;
searchDuration: string;
}> => {
if (searchAfterSortId == null) {
throw Error('Attempted to search after with empty sort id');
}
@ -43,11 +48,13 @@ export const singleSearchAfter = async ({
size: pageSize,
searchAfterSortId,
});
const start = performance.now();
const nextSearchAfterResult: SignalSearchResponse = await services.callCluster(
'search',
searchAfterQuery
);
return nextSearchAfterResult;
const end = performance.now();
return { searchResult: nextSearchAfterResult, searchDuration: makeFloatString(end - start) };
} catch (exc) {
logger.error(`[-] nextSearchAfter threw an error ${exc}`);
throw exc;

View file

@ -89,3 +89,5 @@ export const getGapBetweenRuns = ({
const drift = diff.subtract(intervalDuration);
return drift.subtract(driftTolerance);
};
export const makeFloatString = (num: number): string => Number(num).toFixed(2);

View file

@ -13,17 +13,32 @@ import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types';
interface GetRuleStatusSavedObject {
services: AlertServices;
currentStatusSavedObject: SavedObject<IRuleSavedAttributesSavedObjectAttributes>;
lastLookBackDate: string | null | undefined;
bulkCreateTimes: string[] | null | undefined;
searchAfterTimes: string[] | null | undefined;
}
export const writeCurrentStatusSucceeded = async ({
services,
currentStatusSavedObject,
lastLookBackDate,
bulkCreateTimes,
searchAfterTimes,
}: GetRuleStatusSavedObject): Promise<void> => {
const sDate = new Date().toISOString();
currentStatusSavedObject.attributes.status = 'succeeded';
currentStatusSavedObject.attributes.statusDate = sDate;
currentStatusSavedObject.attributes.lastSuccessAt = sDate;
currentStatusSavedObject.attributes.lastSuccessMessage = 'succeeded';
if (lastLookBackDate != null) {
currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate;
}
if (bulkCreateTimes != null) {
currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes;
}
if (searchAfterTimes != null) {
currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes;
}
await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, {
...currentStatusSavedObject.attributes,
});

View file

@ -48,6 +48,7 @@ export const writeGapErrorToSavedObject = async ({
lastSuccessAt: currentStatusSavedObject.attributes.lastSuccessAt,
lastFailureMessage: `Signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" has a time gap of ${gap.humanize()} (${gap.asMilliseconds()}ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.`,
lastSuccessMessage: currentStatusSavedObject.attributes.lastSuccessMessage,
gap: gap.humanize(),
});
if (ruleStatusSavedObjects.saved_objects.length >= 6) {

View file

@ -19,6 +19,9 @@ interface SignalRuleExceptionParams {
message: string;
services: AlertServices;
name: string;
lastLookBackDate?: string | null | undefined;
bulkCreateTimes?: string[] | null | undefined;
searchAfterTimes?: string[] | null | undefined;
}
export const writeSignalRuleExceptionToSavedObject = async ({
@ -30,6 +33,9 @@ export const writeSignalRuleExceptionToSavedObject = async ({
ruleStatusSavedObjects,
ruleId,
name,
lastLookBackDate,
bulkCreateTimes,
searchAfterTimes,
}: SignalRuleExceptionParams): Promise<void> => {
logger.error(
`Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${message}`
@ -39,6 +45,15 @@ export const writeSignalRuleExceptionToSavedObject = async ({
currentStatusSavedObject.attributes.statusDate = sDate;
currentStatusSavedObject.attributes.lastFailureAt = sDate;
currentStatusSavedObject.attributes.lastFailureMessage = message;
if (lastLookBackDate) {
currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate;
}
if (bulkCreateTimes) {
currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes;
}
if (searchAfterTimes) {
currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes;
}
// current status is failing
await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, {
...currentStatusSavedObject.attributes,