Skip to content

Commit c0e2275

Browse files
author
Aaron Caldwell
authored
[7.x] Introduce geo-threshold alerts (#76285) (#79591)
1 parent f09a57e commit c0e2275

File tree

31 files changed

+2691
-9
lines changed

31 files changed

+2691
-9
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { i18n } from '@kbn/i18n';
8+
import { schema } from '@kbn/config-schema';
9+
import { Service } from '../../types';
10+
import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../common';
11+
import { getGeoThresholdExecutor } from './geo_threshold';
12+
import {
13+
ActionGroup,
14+
AlertServices,
15+
ActionVariable,
16+
AlertTypeState,
17+
} from '../../../../alerts/server';
18+
19+
export const GEO_THRESHOLD_ID = '.geo-threshold';
20+
export type TrackingEvent = 'entered' | 'exited';
21+
export const ActionGroupId = 'tracking threshold met';
22+
23+
const actionVariableContextToEntityDateTimeLabel = i18n.translate(
24+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityDateTimeLabel',
25+
{
26+
defaultMessage: `The time the entity was detected in the current boundary`,
27+
}
28+
);
29+
30+
const actionVariableContextFromEntityDateTimeLabel = i18n.translate(
31+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDateTimeLabel',
32+
{
33+
defaultMessage: `The last time the entity was recorded in the previous boundary`,
34+
}
35+
);
36+
37+
const actionVariableContextToEntityLocationLabel = i18n.translate(
38+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityLocationLabel',
39+
{
40+
defaultMessage: 'The most recently captured location of the entity',
41+
}
42+
);
43+
44+
const actionVariableContextCrossingLineLabel = i18n.translate(
45+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingLineLabel',
46+
{
47+
defaultMessage:
48+
'GeoJSON line connecting the two locations that were used to determine the crossing event',
49+
}
50+
);
51+
52+
const actionVariableContextFromEntityLocationLabel = i18n.translate(
53+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityLocationLabel',
54+
{
55+
defaultMessage: 'The previously captured location of the entity',
56+
}
57+
);
58+
59+
const actionVariableContextToBoundaryIdLabel = i18n.translate(
60+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCurrentBoundaryIdLabel',
61+
{
62+
defaultMessage: 'The current boundary id containing the entity (if any)',
63+
}
64+
);
65+
66+
const actionVariableContextToBoundaryNameLabel = i18n.translate(
67+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextToBoundaryNameLabel',
68+
{
69+
defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located',
70+
}
71+
);
72+
73+
const actionVariableContextFromBoundaryNameLabel = i18n.translate(
74+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryNameLabel',
75+
{
76+
defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located',
77+
}
78+
);
79+
80+
const actionVariableContextFromBoundaryIdLabel = i18n.translate(
81+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryIdLabel',
82+
{
83+
defaultMessage: 'The previous boundary id containing the entity (if any)',
84+
}
85+
);
86+
87+
const actionVariableContextToEntityDocumentIdLabel = i18n.translate(
88+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingDocumentIdLabel',
89+
{
90+
defaultMessage: 'The id of the crossing entity document',
91+
}
92+
);
93+
94+
const actionVariableContextFromEntityDocumentIdLabel = i18n.translate(
95+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDocumentIdLabel',
96+
{
97+
defaultMessage: 'The id of the crossing entity document',
98+
}
99+
);
100+
101+
const actionVariableContextTimeOfDetectionLabel = i18n.translate(
102+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextTimeOfDetectionLabel',
103+
{
104+
defaultMessage: 'The alert interval end time this change was recorded',
105+
}
106+
);
107+
108+
const actionVariableContextEntityIdLabel = i18n.translate(
109+
'xpack.alertingBuiltins.geoThreshold.actionVariableContextEntityIdLabel',
110+
{
111+
defaultMessage: 'The entity ID of the document that triggered the alert',
112+
}
113+
);
114+
115+
const actionVariables = {
116+
context: [
117+
// Alert-specific data
118+
{ name: 'entityId', description: actionVariableContextEntityIdLabel },
119+
{ name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel },
120+
{ name: 'crossingLine', description: actionVariableContextCrossingLineLabel },
121+
122+
// Corresponds to a specific document in the entity-index
123+
{ name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel },
124+
{
125+
name: 'toEntityDateTime',
126+
description: actionVariableContextToEntityDateTimeLabel,
127+
},
128+
{ name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel },
129+
130+
// Corresponds to a specific document in the boundary-index
131+
{ name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel },
132+
{ name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel },
133+
134+
// Corresponds to a specific document in the entity-index (from)
135+
{ name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel },
136+
{ name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel },
137+
{ name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel },
138+
139+
// Corresponds to a specific document in the boundary-index (from)
140+
{ name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel },
141+
{ name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel },
142+
],
143+
};
144+
145+
export const ParamsSchema = schema.object({
146+
index: schema.string({ minLength: 1 }),
147+
indexId: schema.string({ minLength: 1 }),
148+
geoField: schema.string({ minLength: 1 }),
149+
entity: schema.string({ minLength: 1 }),
150+
dateField: schema.string({ minLength: 1 }),
151+
trackingEvent: schema.string({ minLength: 1 }),
152+
boundaryType: schema.string({ minLength: 1 }),
153+
boundaryIndexTitle: schema.string({ minLength: 1 }),
154+
boundaryIndexId: schema.string({ minLength: 1 }),
155+
boundaryGeoField: schema.string({ minLength: 1 }),
156+
boundaryNameField: schema.maybe(schema.string({ minLength: 1 })),
157+
delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })),
158+
});
159+
160+
export interface GeoThresholdParams {
161+
index: string;
162+
indexId: string;
163+
geoField: string;
164+
entity: string;
165+
dateField: string;
166+
trackingEvent: string;
167+
boundaryType: string;
168+
boundaryIndexTitle: string;
169+
boundaryIndexId: string;
170+
boundaryGeoField: string;
171+
boundaryNameField?: string;
172+
delayOffsetWithUnits?: string;
173+
}
174+
175+
export function getAlertType(
176+
service: Omit<Service, 'indexThreshold'>
177+
): {
178+
defaultActionGroupId: string;
179+
actionGroups: ActionGroup[];
180+
executor: ({
181+
previousStartedAt: currIntervalStartTime,
182+
startedAt: currIntervalEndTime,
183+
services,
184+
params,
185+
alertId,
186+
state,
187+
}: {
188+
previousStartedAt: Date | null;
189+
startedAt: Date;
190+
services: AlertServices;
191+
params: GeoThresholdParams;
192+
alertId: string;
193+
state: AlertTypeState;
194+
}) => Promise<AlertTypeState>;
195+
validate?: {
196+
params?: {
197+
validate: (object: unknown) => GeoThresholdParams;
198+
};
199+
};
200+
name: string;
201+
producer: string;
202+
id: string;
203+
actionVariables?: {
204+
context?: ActionVariable[];
205+
state?: ActionVariable[];
206+
params?: ActionVariable[];
207+
};
208+
} {
209+
const alertTypeName = i18n.translate('xpack.alertingBuiltins.geoThreshold.alertTypeTitle', {
210+
defaultMessage: 'Geo tracking threshold',
211+
});
212+
213+
const actionGroupName = i18n.translate(
214+
'xpack.alertingBuiltins.geoThreshold.actionGroupThresholdMetTitle',
215+
{
216+
defaultMessage: 'Tracking threshold met',
217+
}
218+
);
219+
220+
return {
221+
id: GEO_THRESHOLD_ID,
222+
name: alertTypeName,
223+
actionGroups: [{ id: ActionGroupId, name: actionGroupName }],
224+
defaultActionGroupId: ActionGroupId,
225+
executor: getGeoThresholdExecutor(service),
226+
producer: BUILT_IN_ALERTS_FEATURE_ID,
227+
validate: {
228+
params: ParamsSchema,
229+
},
230+
actionVariables,
231+
};
232+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { ILegacyScopedClusterClient } from 'kibana/server';
8+
import { SearchResponse } from 'elasticsearch';
9+
import { Logger } from '../../types';
10+
11+
export const OTHER_CATEGORY = 'other';
12+
// Consider dynamically obtaining from config?
13+
const MAX_TOP_LEVEL_QUERY_SIZE = 0;
14+
const MAX_SHAPES_QUERY_SIZE = 10000;
15+
const MAX_BUCKETS_LIMIT = 65535;
16+
17+
export async function getShapesFilters(
18+
boundaryIndexTitle: string,
19+
boundaryGeoField: string,
20+
geoField: string,
21+
callCluster: ILegacyScopedClusterClient['callAsCurrentUser'],
22+
log: Logger,
23+
alertId: string,
24+
boundaryNameField?: string
25+
) {
26+
const filters: Record<string, unknown> = {};
27+
const shapesIdsNamesMap: Record<string, unknown> = {};
28+
// Get all shapes in index
29+
const boundaryData: SearchResponse<Record<string, unknown>> = await callCluster('search', {
30+
index: boundaryIndexTitle,
31+
body: {
32+
size: MAX_SHAPES_QUERY_SIZE,
33+
},
34+
});
35+
boundaryData.hits.hits.forEach(({ _index, _id }) => {
36+
filters[_id] = {
37+
geo_shape: {
38+
[geoField]: {
39+
indexed_shape: {
40+
index: _index,
41+
id: _id,
42+
path: boundaryGeoField,
43+
},
44+
},
45+
},
46+
};
47+
});
48+
if (boundaryNameField) {
49+
boundaryData.hits.hits.forEach(
50+
({ _source, _id }: { _source: Record<string, unknown>; _id: string }) => {
51+
shapesIdsNamesMap[_id] = _source[boundaryNameField];
52+
}
53+
);
54+
}
55+
return {
56+
shapesFilters: filters,
57+
shapesIdsNamesMap,
58+
};
59+
}
60+
61+
export async function executeEsQueryFactory(
62+
{
63+
entity,
64+
index,
65+
dateField,
66+
boundaryGeoField,
67+
geoField,
68+
boundaryIndexTitle,
69+
}: {
70+
entity: string;
71+
index: string;
72+
dateField: string;
73+
boundaryGeoField: string;
74+
geoField: string;
75+
boundaryIndexTitle: string;
76+
boundaryNameField?: string;
77+
},
78+
{ callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] },
79+
log: Logger,
80+
shapesFilters: Record<string, unknown>
81+
) {
82+
return async (
83+
gteDateTime: Date | null,
84+
ltDateTime: Date | null
85+
): Promise<SearchResponse<unknown> | undefined> => {
86+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
87+
const esQuery: Record<string, any> = {
88+
index,
89+
body: {
90+
size: MAX_TOP_LEVEL_QUERY_SIZE,
91+
aggs: {
92+
shapes: {
93+
filters: {
94+
other_bucket_key: OTHER_CATEGORY,
95+
filters: shapesFilters,
96+
},
97+
aggs: {
98+
entitySplit: {
99+
terms: {
100+
size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2),
101+
field: entity,
102+
},
103+
aggs: {
104+
entityHits: {
105+
top_hits: {
106+
size: 1,
107+
sort: [
108+
{
109+
[dateField]: {
110+
order: 'desc',
111+
},
112+
},
113+
],
114+
docvalue_fields: [entity, dateField, geoField],
115+
_source: false,
116+
},
117+
},
118+
},
119+
},
120+
},
121+
},
122+
},
123+
query: {
124+
bool: {
125+
must: [],
126+
filter: [
127+
{
128+
match_all: {},
129+
},
130+
{
131+
range: {
132+
[dateField]: {
133+
...(gteDateTime ? { gte: gteDateTime } : {}),
134+
lt: ltDateTime, // 'less than' to prevent overlap between intervals
135+
format: 'strict_date_optional_time',
136+
},
137+
},
138+
},
139+
],
140+
should: [],
141+
must_not: [],
142+
},
143+
},
144+
stored_fields: ['*'],
145+
docvalue_fields: [
146+
{
147+
field: dateField,
148+
format: 'date_time',
149+
},
150+
],
151+
},
152+
};
153+
154+
let esResult: SearchResponse<unknown> | undefined;
155+
try {
156+
esResult = await callCluster('search', esQuery);
157+
} catch (err) {
158+
log.warn(`${err.message}`);
159+
}
160+
return esResult;
161+
};
162+
}

0 commit comments

Comments
 (0)