Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce geo-threshold alerts #76285

Merged
merged 92 commits into from
Oct 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
d9feaf8
Sample server set up
Jul 6, 2020
5e04cb5
Merge remote-tracking branch 'upstream/master' into maps-alerts
Jul 9, 2020
7b1e63a
Merge remote-tracking branch 'upstream/master' into maps-alerts
Jul 13, 2020
7fd2e12
Front end producing fly out panel launched from top nav
Jul 17, 2020
3aee55d
Merge remote-tracking branch 'upstream/master' into maps-alerts
Jul 31, 2020
6498731
Add index threshold to maps
Aug 3, 2020
b240f26
Clean up
Aug 3, 2020
15df0e8
Executor creates and makes agg query w/ switch for different shape types
Aug 5, 2020
150f386
Alerts working in initial tests
Aug 13, 2020
a8361ae
Merge remote-tracking branch 'upstream/master' into maps-alerts
Aug 13, 2020
03d16b4
Merge remote-tracking branch 'upstream/master' into maps-alerts
Aug 14, 2020
ca57405
Geo threshold UI started with some major pieces in place. Back-end st…
Aug 18, 2020
ae7f61e
UI working without validation
Aug 25, 2020
a092358
Merge remote-tracking branch 'upstream/master' into maps-alerts
Aug 25, 2020
c0bf66d
Move back-end logic to alerts built-ins
Aug 31, 2020
7df4d2e
Merge remote-tracking branch 'upstream/master' into maps-alerts
Aug 31, 2020
a1733a1
Clean up maps mods
Aug 31, 2020
13d82da
Add schema validation. Clean up unused logic on back end
Aug 31, 2020
0b38bdb
Revise queries and comparison logic to handle movements within shapes…
Sep 2, 2020
01f6964
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 2, 2020
cb93339
Remove old files from early tests
Sep 3, 2020
76dc00a
Add geofield to query
Sep 4, 2020
c173ee9
Make date field part of query dynamic
Sep 4, 2020
77f1e3e
Update code to account for dynamic date. Also grab location data to p…
Sep 4, 2020
944a4f8
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 4, 2020
078d781
Update two missed refs to date and location
Sep 4, 2020
16400eb
Select an index -> Select entity
Sep 4, 2020
97bec60
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 10, 2020
1f46fd6
Add context variables for better connector tracking
Sep 14, 2020
61e705c
Update sorting in both query and lodash handling
Sep 14, 2020
a167988
Track id of crossing entity
Sep 14, 2020
8943656
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 15, 2020
1f95cef
Capture index pattern id and title in UI
Sep 15, 2020
093be29
Correctly show invalid entries
Sep 15, 2020
fafc8b6
Add alert params create validation
Sep 16, 2020
51da2b1
i18n fixes and clean up
Sep 16, 2020
aba7e31
Fix jest test error
Sep 16, 2020
6443b61
Linting errors
Sep 16, 2020
1dfbcce
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 16, 2020
d4cfe77
Finish updates to passed args index title and id
Sep 16, 2020
4374310
Remove unused data plugin ref
Sep 16, 2020
44d756e
Review feedback. Set condition to 'entered' for review. Only allow nu…
Sep 17, 2020
d2ae160
Clean up typing errors & some general clean up
Sep 18, 2020
58d9cbd
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 18, 2020
83781f2
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 21, 2020
a50ecb9
More type updates
Sep 22, 2020
ba16694
Review feedback. Update param names, logging, some clean up
Sep 22, 2020
83f0c6d
Review feedback. Re-enable 'exited'. Add 'ip' field to possible entit…
Sep 22, 2020
afed143
Call large query on first run only, carry previous results through state
Sep 23, 2020
6ebd455
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 23, 2020
db523c2
Get filter shapes only once initially then pull from state
Sep 24, 2020
130e617
Use human-readable boundary name for alert instance if avab. or fall …
Sep 24, 2020
036a46b
Type fixes
Sep 24, 2020
2f77334
Update order of args in call to getShapesFilters
Sep 24, 2020
68e9414
Set boundaryNameField optional in interface
Sep 24, 2020
79971f8
Types clean up
Sep 24, 2020
76e699a
Only show entering option (for review)
Sep 24, 2020
49652bd
Add server side jest tests for validation and geo threshold transform…
Sep 29, 2020
e1af8a3
Add client side validation tests
Sep 29, 2020
beca47b
Merge remote-tracking branch 'upstream/master' into maps-alerts
Sep 29, 2020
e4e6fa9
i18n fix
Sep 29, 2020
e139fd4
Review feedback. Add linestring context value, clean up types, genera…
Sep 29, 2020
f0491bd
Fix jest test issue, lodash get missing object
Sep 29, 2020
50e0926
Review feedback. Update context variables
Sep 30, 2020
da52a2c
i18n fix
Sep 30, 2020
45eee57
Reset index-related fields on change
Sep 30, 2020
5add92c
Only include geo_point for tracking index
Sep 30, 2020
997e0f2
Only use geo_shapes for boundaries
Sep 30, 2020
fc8090b
Update snapshot
Sep 30, 2020
a91c3ac
Allow user to select nothing on boundary name
Sep 30, 2020
87116fa
Clean up and type updates
Sep 30, 2020
8f346d3
Pre-populate index & boundary index related fields with first options
Sep 30, 2020
604d107
Auto-populate category based upon index selected
Sep 30, 2020
035714b
Type updates
Sep 30, 2020
84513c8
Code the geo fields as wkt
Sep 30, 2020
05cac9e
Fix wkt spacing
Oct 1, 2020
c5a9323
Remove temp testing logic
Oct 1, 2020
30aad87
Add limited time window to first run
Oct 1, 2020
832ddd4
Type updates
Oct 1, 2020
e2a305f
Merge remote-tracking branch 'upstream/master' into maps-alerts
Oct 1, 2020
b9d75bd
Add delay offset
Oct 2, 2020
523de58
Handle sorting manually vs. using lodash
Oct 2, 2020
a84b78f
Set sub agg query size to max 10k. Fixes issue where points in 'other…
Oct 3, 2020
bc5d4d1
Don't clobber previously defined entity with auto-complete on alert edit
Oct 3, 2020
ecbac9e
Fall back to 'boundaryId' from 'fromBoundaryName' if human-readable n…
Oct 3, 2020
e3c053d
Review feedback. Type updates, clean up, i18n changes
Oct 3, 2020
8dbc8a4
Review feedback. ES query const tweaks
Oct 4, 2020
d2ca92c
Add barebones server to triggers_actions_ui and connect config up thr…
Oct 4, 2020
1acdf69
Type fixes
Oct 4, 2020
9e08608
Merge remote-tracking branch 'upstream/master' into maps-alerts
Oct 4, 2020
64dd8d5
Merge remote-tracking branch 'upstream/master' into maps-alerts
Oct 5, 2020
19ff93f
Review feedback. Remove unneeded colons in UI
Oct 5, 2020
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { Service } from '../../types';
import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../common';
import { getGeoThresholdExecutor } from './geo_threshold';
import {
ActionGroup,
AlertServices,
ActionVariable,
AlertTypeState,
} from '../../../../alerts/server';

export const GEO_THRESHOLD_ID = '.geo-threshold';
export type TrackingEvent = 'entered' | 'exited';
export const ActionGroupId = 'tracking threshold met';

const actionVariableContextToEntityDateTimeLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityDateTimeLabel',
{
defaultMessage: `The time the entity was detected in the current boundary`,
kindsun marked this conversation as resolved.
Show resolved Hide resolved
}
);

const actionVariableContextFromEntityDateTimeLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDateTimeLabel',
{
defaultMessage: `The last time the entity was recorded in the previous boundary`,
}
);

const actionVariableContextToEntityLocationLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityLocationLabel',
{
defaultMessage: 'The most recently captured location of the entity',
}
);

const actionVariableContextCrossingLineLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingLineLabel',
{
defaultMessage:
'GeoJSON line connecting the two locations that were used to determine the crossing event',
}
);

const actionVariableContextFromEntityLocationLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityLocationLabel',
{
defaultMessage: 'The previously captured location of the entity',
}
);

const actionVariableContextToBoundaryIdLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCurrentBoundaryIdLabel',
{
defaultMessage: 'The current boundary id containing the entity (if any)',
}
);

const actionVariableContextToBoundaryNameLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToBoundaryNameLabel',
{
defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located',
}
);

const actionVariableContextFromBoundaryNameLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryNameLabel',
{
defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located',
}
);

const actionVariableContextFromBoundaryIdLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryIdLabel',
{
defaultMessage: 'The previous boundary id containing the entity (if any)',
}
);

const actionVariableContextToEntityDocumentIdLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingDocumentIdLabel',
{
defaultMessage: 'The id of the crossing entity document',
}
);

const actionVariableContextFromEntityDocumentIdLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDocumentIdLabel',
{
defaultMessage: 'The id of the crossing entity document',
}
);

const actionVariableContextTimeOfDetectionLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextTimeOfDetectionLabel',
{
defaultMessage: 'The alert interval end time this change was recorded',
}
);

const actionVariableContextEntityIdLabel = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionVariableContextEntityIdLabel',
{
defaultMessage: 'The entity ID of the document that triggered the alert',
}
);

const actionVariables = {
context: [
// Alert-specific data
{ name: 'entityId', description: actionVariableContextEntityIdLabel },
{ name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel },
{ name: 'crossingLine', description: actionVariableContextCrossingLineLabel },

// Corresponds to a specific document in the entity-index
{ name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel },
{
name: 'toEntityDateTime',
description: actionVariableContextToEntityDateTimeLabel,
},
{ name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel },

// Corresponds to a specific document in the boundary-index
{ name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel },
{ name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel },

// Corresponds to a specific document in the entity-index (from)
{ name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel },
{ name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel },
{ name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel },

// Corresponds to a specific document in the boundary-index (from)
{ name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel },
{ name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel },
],
};

export const ParamsSchema = schema.object({
index: schema.string({ minLength: 1 }),
indexId: schema.string({ minLength: 1 }),
geoField: schema.string({ minLength: 1 }),
entity: schema.string({ minLength: 1 }),
dateField: schema.string({ minLength: 1 }),
trackingEvent: schema.string({ minLength: 1 }),
boundaryType: schema.string({ minLength: 1 }),
boundaryIndexTitle: schema.string({ minLength: 1 }),
boundaryIndexId: schema.string({ minLength: 1 }),
boundaryGeoField: schema.string({ minLength: 1 }),
boundaryNameField: schema.maybe(schema.string({ minLength: 1 })),
delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })),
});

export interface GeoThresholdParams {
index: string;
indexId: string;
geoField: string;
entity: string;
dateField: string;
trackingEvent: string;
boundaryType: string;
boundaryIndexTitle: string;
boundaryIndexId: string;
boundaryGeoField: string;
boundaryNameField?: string;
delayOffsetWithUnits?: string;
}

export function getAlertType(
service: Omit<Service, 'indexThreshold'>
): {
defaultActionGroupId: string;
actionGroups: ActionGroup[];
executor: ({
previousStartedAt: currIntervalStartTime,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth noting that I believe the previousStartedAt will get reset to null when an alert is disabled() and then enabled() again - @mikecote that's still right? Actually, I'm thinking of the alert state itself, but guessing this is implicitly part of the alert state.

For the index threshold alert, we require the user to explicitly pass a window of time to use, to calculate the dates used to get the range of values to test.

There's some code here from the security solution that also uses previousStartedAt that you might want to look over:

export const getGapMaxCatchupRatio = ({
logger,
previousStartedAt,
unit,
buildRuleMessage,
ruleParamsFrom,
interval,
}: {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed there's room for improvement here. Not sure if should be part of this PR or a future optimization. Will discuss!

startedAt: currIntervalEndTime,
services,
params,
alertId,
state,
}: {
previousStartedAt: Date | null;
startedAt: Date;
services: AlertServices;
params: GeoThresholdParams;
alertId: string;
state: AlertTypeState;
}) => Promise<AlertTypeState>;
validate?: {
params?: {
validate: (object: unknown) => GeoThresholdParams;
};
};
name: string;
producer: string;
id: string;
actionVariables?: {
context?: ActionVariable[];
state?: ActionVariable[];
params?: ActionVariable[];
};
} {
const alertTypeName = i18n.translate('xpack.alertingBuiltins.geoThreshold.alertTypeTitle', {
defaultMessage: 'Geo tracking threshold',
});

const actionGroupName = i18n.translate(
'xpack.alertingBuiltins.geoThreshold.actionGroupThresholdMetTitle',
{
defaultMessage: 'Tracking threshold met',
}
);

return {
id: GEO_THRESHOLD_ID,
name: alertTypeName,
actionGroups: [{ id: ActionGroupId, name: actionGroupName }],
defaultActionGroupId: ActionGroupId,
executor: getGeoThresholdExecutor(service),
producer: BUILT_IN_ALERTS_FEATURE_ID,
validate: {
params: ParamsSchema,
},
actionVariables,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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 { ILegacyScopedClusterClient } from 'kibana/server';
import { SearchResponse } from 'elasticsearch';
import { Logger } from '../../types';

export const OTHER_CATEGORY = 'other';
// Consider dynamically obtaining from config?
const MAX_TOP_LEVEL_QUERY_SIZE = 0;
const MAX_SHAPES_QUERY_SIZE = 10000;
const MAX_BUCKETS_LIMIT = 65535;

export async function getShapesFilters(
boundaryIndexTitle: string,
boundaryGeoField: string,
geoField: string,
callCluster: ILegacyScopedClusterClient['callAsCurrentUser'],
log: Logger,
alertId: string,
boundaryNameField?: string
) {
const filters: Record<string, unknown> = {};
const shapesIdsNamesMap: Record<string, unknown> = {};
// Get all shapes in index
const boundaryData: SearchResponse<Record<string, unknown>> = await callCluster('search', {
index: boundaryIndexTitle,
body: {
size: MAX_SHAPES_QUERY_SIZE,
},
});
boundaryData.hits.hits.forEach(({ _index, _id }) => {
filters[_id] = {
geo_shape: {
[geoField]: {
indexed_shape: {
index: _index,
id: _id,
path: boundaryGeoField,
},
},
},
};
});
if (boundaryNameField) {
boundaryData.hits.hits.forEach(
({ _source, _id }: { _source: Record<string, unknown>; _id: string }) => {
shapesIdsNamesMap[_id] = _source[boundaryNameField];
}
);
}
return {
shapesFilters: filters,
shapesIdsNamesMap,
};
}

export async function executeEsQueryFactory(
kindsun marked this conversation as resolved.
Show resolved Hide resolved
{
entity,
index,
dateField,
boundaryGeoField,
geoField,
boundaryIndexTitle,
}: {
kindsun marked this conversation as resolved.
Show resolved Hide resolved
entity: string;
index: string;
dateField: string;
boundaryGeoField: string;
geoField: string;
boundaryIndexTitle: string;
boundaryNameField?: string;
},
{ callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] },
log: Logger,
shapesFilters: Record<string, unknown>
) {
return async (
gteDateTime: Date | null,
ltDateTime: Date | null
): Promise<SearchResponse<unknown> | undefined> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const esQuery: Record<string, any> = {
index,
body: {
size: MAX_TOP_LEVEL_QUERY_SIZE,
aggs: {
shapes: {
filters: {
other_bucket_key: OTHER_CATEGORY,
filters: shapesFilters,
},
aggs: {
entitySplit: {
terms: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add size param here:

fwiw: It's tough to determine this up-front. This is because it is a nested agg, and the number of shapes-filters determines the amount of buckets you have available in the sub-terms agg. The top-hits also adds a bucket.

So I would try something like MAX_BUCKET_NUMBER/(shapesFilters.length * 2)

(* 2 because the top-hots creates a bucket for each term as well).

^ fwiw this is completely off the cuff and we should doulbe check with ES-team to know how to determine the agg-limits more exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, this was the issue preventing triggered alerts from picking up the latest point from other. The reason it usually worked fine within shapes was that there was usually <= 10 unique entities in any given shape. Obviously in other there were far more.

size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2),
field: entity,
},
aggs: {
entityHits: {
top_hits: {
size: 1,
sort: [
{
[dateField]: {
order: 'desc',
},
},
],
docvalue_fields: [entity, dateField, geoField],
_source: false,
},
},
},
},
},
},
},
query: {
bool: {
must: [],
filter: [
{
match_all: {},
},
{
range: {
[dateField]: {
...(gteDateTime ? { gte: gteDateTime } : {}),
lt: ltDateTime, // 'less than' to prevent overlap between intervals
format: 'strict_date_optional_time',
},
},
},
],
should: [],
must_not: [],
},
},
stored_fields: ['*'],
docvalue_fields: [
{
field: dateField,
format: 'date_time',
},
],
},
};

let esResult: SearchResponse<unknown> | undefined;
try {
esResult = await callCluster('search', esQuery);
} catch (err) {
log.warn(`${err.message}`);
}
return esResult;
};
}
Loading