[maps][geo containment alert] fix boundaries are not re-fetched when query changes (#157408)

Fixes https://github.com/elastic/kibana/issues/157068 and
https://github.com/elastic/kibana/issues/157066

<img width="800" alt="Screen Shot 2023-05-11 at 10 16 31 AM"
src="3ee3b573-f3f9-497f-afef-1927f43681a7">

Throwing errors provides better user feedback when alerting rules
encounter problems. PR updates tracking containment rule to throw when:
* No tracking containment boundaries are found. Prior to this PR, entity
containment search request would throw because "filters" was undefined.
Root cause of https://github.com/elastic/kibana/issues/157068
* Throw when entity containment search request throws. Prior to this PR,
entity containment search request failure was just logged as server
warning.

PR updates boundaries cache to refresh whenever any parameters change
that result in boundary filters needing to be updated, resolving
https://github.com/elastic/kibana/issues/157066

PR defines common type "RegisterRuleTypesParams" for
registerBuiltInRuleTypes parameter to avoid duplicate type definitions
for the same thing.

PR also cleans up some tech debt in tracking containment rule
* "alert" name replaced with "rule" some time ago. PR renames
"alert_type" to "rule_type".
* Breaks types defined in "alert_type" into separate "types" file
* Breaks "geoContainment.ts" and "geoContainment.test.ts" files into
separate files, "executor.ts", "lib/get_entities_and_generate_alerts",
and "lib/transform_results.ts"
* Renames type "GeoContainmentParams" to "GeoContainmentRuleParams"
* Renames type "GeoContainmentExtractedParams" to
"GeoContainmentExtractedRuleParams"
* Renames type "GeoContainmentState" to "GeoContainmentRuleState"
* Renames type "GeoContainmentInstanceState" to
"GeoContainmentAlertInstanceState"
* Renames type "GeoContainmentInstanceContext" to
"GeoContainmentAlertInstanceContext"
* Renames type "GeoContainmentAlertType" to "GeoContainmentRuleType"

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nick Peihl <nickpeihl@gmail.com>
This commit is contained in:
Nathan Reese 2023-05-17 06:23:23 -06:00 committed by GitHub
parent 596c7b3e70
commit b736f02bc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1604 additions and 1635 deletions

View file

@ -10,7 +10,7 @@ import { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { TRANSFORM_RULE_TYPE } from '@kbn/transform-plugin/common';
import { ID as IndexThreshold } from './rule_types/index_threshold/rule_type';
import { GEO_CONTAINMENT_ID as GeoContainment } from './rule_types/geo_containment/alert_type';
import { GEO_CONTAINMENT_ID as GeoContainment } from './rule_types/geo_containment';
import { ES_QUERY_ID as ElasticsearchQuery } from './rule_types/es_query/constants';
import { STACK_ALERTS_FEATURE_ID } from '../common';

View file

@ -5,16 +5,10 @@
* 2.0.
*/
import { CoreSetup } from '@kbn/core/server';
import { AlertingSetup } from '../../types';
import type { RegisterRuleTypesParams } from '../types';
import { getRuleType } from './rule_type';
interface RegisterParams {
alerting: AlertingSetup;
core: CoreSetup;
}
export function register(params: RegisterParams) {
export function register(params: RegisterRuleTypesParams) {
const { alerting, core } = params;
alerting.registerType(getRuleType(core));
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`alertType alert type creation structure is the expected value 1`] = `
exports[`ruleType alert type creation structure is the expected value 1`] = `
Object {
"context": Array [
Object {

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const ActionGroupId = 'Tracked entity contained';
export const RecoveryActionGroupId = 'notGeoContained';
export const GEO_CONTAINMENT_ID = '.geo-containment';
export const OTHER_CATEGORY = 'other';

View file

@ -1,221 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { Logger } from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
fromKueryExpression,
toElasticsearchQuery,
luceneStringToDsl,
DataViewBase,
Query,
} from '@kbn/es-query';
export const OTHER_CATEGORY = 'other';
// Consider dynamically obtaining from config?
const MAX_SHAPES_QUERY_SIZE = 10000;
const MAX_BUCKETS_LIMIT = 65535;
interface BoundaryHit {
_index: string;
_id: string;
fields?: Record<string, unknown[]>;
}
export const getEsFormattedQuery = (query: Query, indexPattern?: DataViewBase) => {
let esFormattedQuery;
const queryLanguage = query.language;
if (queryLanguage === 'kuery') {
const ast = fromKueryExpression(query.query);
esFormattedQuery = toElasticsearchQuery(ast, indexPattern);
} else {
esFormattedQuery = luceneStringToDsl(query.query);
}
return esFormattedQuery;
};
export async function getShapesFilters(
boundaryIndexTitle: string,
boundaryGeoField: string,
geoField: string,
esClient: ElasticsearchClient,
log: Logger,
alertId: string,
boundaryNameField?: string,
boundaryIndexQuery?: Query
) {
const filters: Record<string, unknown> = {};
const shapesIdsNamesMap: Record<string, unknown> = {};
// Get all shapes in index
const boundaryData = await esClient.search<Record<string, BoundaryHit>>({
index: boundaryIndexTitle,
body: {
size: MAX_SHAPES_QUERY_SIZE,
_source: false,
fields: boundaryNameField ? [boundaryNameField] : [],
...(boundaryIndexQuery ? { query: getEsFormattedQuery(boundaryIndexQuery) } : {}),
},
});
for (let i = 0; i < boundaryData.hits.hits.length; i++) {
const boundaryHit: BoundaryHit = boundaryData.hits.hits[i];
filters[boundaryHit._id] = {
geo_shape: {
[geoField]: {
indexed_shape: {
index: boundaryHit._index,
id: boundaryHit._id,
path: boundaryGeoField,
},
},
},
};
if (
boundaryNameField &&
boundaryHit.fields &&
boundaryHit.fields[boundaryNameField] &&
boundaryHit.fields[boundaryNameField].length
) {
// fields API always returns an array, grab first value
shapesIdsNamesMap[boundaryHit._id] = boundaryHit.fields[boundaryNameField][0];
}
}
return {
shapesFilters: filters,
shapesIdsNamesMap,
};
}
export async function executeEsQueryFactory(
{
entity,
index,
dateField,
boundaryGeoField,
geoField,
boundaryIndexTitle,
indexQuery,
}: {
entity: string;
index: string;
dateField: string;
boundaryGeoField: string;
geoField: string;
boundaryIndexTitle: string;
boundaryNameField?: string;
indexQuery?: Query;
},
esClient: ElasticsearchClient,
log: Logger,
shapesFilters: Record<string, unknown>
) {
return async (
gteDateTime: Date | null,
ltDateTime: Date | null
): Promise<estypes.SearchResponse<unknown> | undefined> => {
let esFormattedQuery;
if (indexQuery) {
const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null;
const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null;
const dateRangeUpdatedQuery =
indexQuery.language === 'kuery'
? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})`
: `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`;
esFormattedQuery = getEsFormattedQuery({
query: dateRangeUpdatedQuery,
language: indexQuery.language,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const esQuery: Record<string, any> = {
index,
body: {
size: 0, // do not fetch hits
aggs: {
shapes: {
filters: {
other_bucket_key: OTHER_CATEGORY,
filters: shapesFilters,
},
aggs: {
entitySplit: {
terms: {
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,
{
field: dateField,
format: 'strict_date_optional_time',
},
geoField,
],
_source: false,
},
},
},
},
},
},
},
query: esFormattedQuery
? esFormattedQuery
: {
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: estypes.SearchResponse<unknown> | undefined;
try {
esResult = await esClient.search(esQuery);
} catch (err) {
log.warn(`${err.message}`);
}
return esResult;
};
}

View file

@ -0,0 +1,253 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { RuleExecutorServicesMock, alertsMock } from '@kbn/alerting-plugin/server/mocks';
import sampleAggsJsonResponse from './tests/es_sample_response.json';
import sampleShapesJsonResponse from './tests/es_sample_response_shapes.json';
import { executor } from './executor';
import type {
GeoContainmentRuleParams,
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
} from './types';
const alertFactory = (contextKeys: unknown[], testAlertActionArr: unknown[]) => ({
create: (instanceId: string) => {
const alertInstance = alertsMock.createAlertFactory.create<
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext
>();
alertInstance.scheduleActions.mockImplementation(
(actionGroupId: string, context?: GeoContainmentAlertInstanceContext) => {
// Check subset of alert for comparison to expected results
// @ts-ignore
const contextSubset = _.pickBy(context, (v, k) => contextKeys.includes(k));
testAlertActionArr.push({
actionGroupId,
instanceId,
context: contextSubset,
});
}
);
return alertInstance;
},
alertLimit: {
getValue: () => 1000,
setLimitReached: () => {},
},
done: () => ({ getRecoveredAlerts: () => [] }),
});
describe('getGeoContainmentExecutor', () => {
// Params needed for all tests
const expectedAlertResults = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: 'kFATGXkBsFLYN2Tj6AAk',
entityDocumentId: 'ZVBoGXkBsFLYN2Tj1wmV',
entityId: '0',
entityLocation: 'POINT (-73.99018926545978 40.751759740523994)',
},
instanceId: '0-kFATGXkBsFLYN2Tj6AAk',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: 'kFATGXkBsFLYN2Tj6AAk',
entityDocumentId: 'ZlBoGXkBsFLYN2Tj1wmV',
entityId: '1',
entityLocation: 'POINT (-73.99561604484916 40.75449890457094)',
},
instanceId: '1-kFATGXkBsFLYN2Tj6AAk',
},
];
const testAlertActionArr: unknown[] = [];
const previousStartedAt = new Date('2021-04-27T16:56:11.923Z');
const startedAt = new Date('2021-04-29T16:56:11.923Z');
const geoContainmentParams: GeoContainmentRuleParams = {
index: 'testIndex',
indexId: 'testIndexId',
geoField: 'location',
entity: 'testEntity',
dateField: '@timestamp',
boundaryType: 'testBoundaryType',
boundaryIndexTitle: 'testBoundaryIndexTitle',
boundaryIndexId: 'testBoundaryIndexId',
boundaryGeoField: 'testBoundaryGeoField',
};
const ruleId = 'testAlertId';
const geoContainmentState = {
boundariesRequestMeta: {
geoField: geoContainmentParams.geoField,
boundaryIndexTitle: geoContainmentParams.boundaryIndexTitle,
boundaryGeoField: geoContainmentParams.boundaryGeoField,
},
shapesFilters: {
testShape: 'thisIsAShape',
},
shapesIdsNamesMap: {},
prevLocationMap: {},
};
// Boundary test mocks
const boundaryCall = jest.fn();
const esAggCall = jest.fn();
const contextKeys = Object.keys(expectedAlertResults[0].context);
const esClient = elasticsearchServiceMock.createElasticsearchClient();
// @ts-ignore incomplete return type
esClient.search.mockResponseImplementation(({ index }) => {
if (index === geoContainmentParams.boundaryIndexTitle) {
boundaryCall();
return sampleShapesJsonResponse;
} else {
esAggCall();
return sampleAggsJsonResponse;
}
});
const alertServicesWithSearchMock: RuleExecutorServicesMock = {
...alertsMock.createRuleExecutorServices(),
// @ts-ignore
alertFactory: alertFactory(contextKeys, testAlertActionArr),
// @ts-ignore
scopedClusterClient: {
asCurrentUser: esClient,
},
};
beforeEach(() => {
jest.clearAllMocks();
testAlertActionArr.length = 0;
});
test('should query for shapes if state does not contain shapes', async () => {
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
// @ts-ignore
state: {},
});
if (executionResult && executionResult.state.shapesFilters) {
expect(boundaryCall.mock.calls.length).toBe(1);
expect(esAggCall.mock.calls.length).toBe(1);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should query for shapes if boundaries request meta changes', async () => {
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
// @ts-ignore
state: {
...geoContainmentState,
boundariesRequestMeta: {
...geoContainmentState.boundariesRequestMeta,
geoField: 'otherLocation',
},
},
});
if (executionResult && executionResult.state.shapesFilters) {
expect(boundaryCall.mock.calls.length).toBe(1);
expect(esAggCall.mock.calls.length).toBe(1);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should not query for shapes if state contains shapes', async () => {
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.shapesFilters) {
expect(boundaryCall.mock.calls.length).toBe(0);
expect(esAggCall.mock.calls.length).toBe(1);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should carry through shapes filters in state to next call unmodified', async () => {
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.shapesFilters) {
expect(executionResult.state.shapesFilters).toEqual(geoContainmentState.shapesFilters);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should return previous locations map', async () => {
const expectedPrevLocationMap = {
'0': [
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZVBoGXkBsFLYN2Tj1wmV',
location: [-73.99018926545978, 40.751759740523994],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
],
'1': [
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZlBoGXkBsFLYN2Tj1wmV',
location: [-73.99561604484916, 40.75449890457094],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
],
};
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.prevLocationMap) {
expect(executionResult.state.prevLocationMap).toEqual(expectedPrevLocationMap);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
});

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RuleExecutorOptions } from '../../types';
import {
canSkipBoundariesFetch,
executeEsQuery,
getEntitiesAndGenerateAlerts,
getRecoveredAlertContext,
getShapeFilters,
transformResults,
} from './lib';
import type {
GeoContainmentRuleParams,
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
GeoContainmentRuleState,
} from './types';
import { ActionGroupId, GEO_CONTAINMENT_ID } from './constants';
export async function executor({
previousStartedAt,
startedAt: windowEnd,
services,
params,
rule,
state,
logger,
}: RuleExecutorOptions<
GeoContainmentRuleParams,
GeoContainmentRuleState,
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
typeof ActionGroupId
>): Promise<{ state: GeoContainmentRuleState }> {
const boundariesRequestMeta = {
geoField: params.geoField,
boundaryIndexTitle: params.boundaryIndexTitle,
boundaryGeoField: params.boundaryGeoField,
boundaryNameField: params.boundaryNameField,
boundaryIndexQuery: params.boundaryIndexQuery,
};
const { shapesFilters, shapesIdsNamesMap } =
state.shapesFilters &&
canSkipBoundariesFetch(boundariesRequestMeta, state.boundariesRequestMeta)
? state
: await getShapeFilters(boundariesRequestMeta, services.scopedClusterClient.asCurrentUser);
let windowStart = previousStartedAt;
if (!windowStart) {
logger.debug(`alert ${GEO_CONTAINMENT_ID}:${rule.id} alert initialized. Collecting data`);
// Consider making first time window configurable?
const START_TIME_WINDOW = 1;
windowStart = new Date(windowEnd);
windowStart.setMinutes(windowStart.getMinutes() - START_TIME_WINDOW);
}
const results = await executeEsQuery(
params,
services.scopedClusterClient.asCurrentUser,
shapesFilters,
windowStart,
windowEnd
);
const currLocationMap: Map<string, GeoContainmentAlertInstanceState[]> = transformResults(
results,
params.dateField,
params.geoField
);
const prevLocationMap: Map<string, GeoContainmentAlertInstanceState[]> = new Map([
...Object.entries(
(state.prevLocationMap as Record<string, GeoContainmentAlertInstanceState[]>) || {}
),
]);
const { activeEntities, inactiveEntities } = getEntitiesAndGenerateAlerts(
prevLocationMap,
currLocationMap,
services.alertFactory,
shapesIdsNamesMap,
windowEnd
);
const { getRecoveredAlerts } = services.alertFactory.done();
for (const recoveredAlert of getRecoveredAlerts()) {
const recoveredAlertId = recoveredAlert.getId();
try {
const context = getRecoveredAlertContext({
alertId: recoveredAlertId,
activeEntities,
inactiveEntities,
windowEnd,
});
if (context) {
recoveredAlert.setContext(context);
}
} catch (e) {
logger.warn(`Unable to set alert context for recovered alert, error: ${e.message}`);
}
}
return {
state: {
boundariesRequestMeta,
shapesFilters,
shapesIdsNamesMap,
prevLocationMap: Object.fromEntries(activeEntities),
},
};
}

View file

@ -1,227 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { RuleExecutorServices } from '@kbn/alerting-plugin/server';
import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder';
import {
ActionGroupId,
GeoContainmentInstanceState,
GeoContainmentAlertType,
GeoContainmentInstanceContext,
GeoContainmentState,
} from './alert_type';
import { GEO_CONTAINMENT_ID } from './alert_type';
import { getAlertId, getContainedAlertContext, getRecoveredAlertContext } from './get_context';
// Flatten agg results and get latest locations for each entity
export function transformResults(
results: estypes.SearchResponse<unknown> | undefined,
dateField: string,
geoField: string
): Map<string, GeoContainmentInstanceState[]> {
if (!results) {
return new Map();
}
const buckets = _.get(results, 'aggregations.shapes.buckets', {});
const arrResults = _.flatMap(buckets, (bucket: unknown, bucketKey: string) => {
const subBuckets = _.get(bucket, 'entitySplit.buckets', []);
return _.map(subBuckets, (subBucket) => {
const locationFieldResult = _.get(
subBucket,
`entityHits.hits.hits[0].fields["${geoField}"][0]`,
''
);
const location = locationFieldResult
? _.chain(locationFieldResult)
.split(', ')
.map((coordString) => +coordString)
.reverse()
.value()
: [];
const dateInShape = _.get(
subBucket,
`entityHits.hits.hits[0].fields["${dateField}"][0]`,
null
);
const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`);
return {
location,
shapeLocationId: bucketKey,
entityName: subBucket.key,
dateInShape,
docId,
};
});
});
const orderedResults = _.orderBy(arrResults, ['entityName', 'dateInShape'], ['asc', 'desc'])
// Get unique
.reduce(
(
accu: Map<string, GeoContainmentInstanceState[]>,
el: GeoContainmentInstanceState & { entityName: string }
) => {
const { entityName, ...locationData } = el;
if (entityName) {
if (!accu.has(entityName)) {
accu.set(entityName, []);
}
accu.get(entityName)!.push(locationData);
}
return accu;
},
new Map()
);
return orderedResults;
}
export function getEntitiesAndGenerateAlerts(
prevLocationMap: Map<string, GeoContainmentInstanceState[]>,
currLocationMap: Map<string, GeoContainmentInstanceState[]>,
alertFactory: RuleExecutorServices<
GeoContainmentInstanceState,
GeoContainmentInstanceContext,
typeof ActionGroupId
>['alertFactory'],
shapesIdsNamesMap: Record<string, unknown>,
windowEnd: Date
): {
activeEntities: Map<string, GeoContainmentInstanceState[]>;
inactiveEntities: Map<string, GeoContainmentInstanceState[]>;
} {
const activeEntities: Map<string, GeoContainmentInstanceState[]> = new Map([
...prevLocationMap,
...currLocationMap,
]);
const inactiveEntities: Map<string, GeoContainmentInstanceState[]> = new Map();
activeEntities.forEach((containments, entityName) => {
// Generate alerts
containments.forEach((containment) => {
if (containment.shapeLocationId !== OTHER_CATEGORY) {
const context = getContainedAlertContext({
entityName,
containment,
shapesIdsNamesMap,
windowEnd,
});
alertFactory
.create(getAlertId(entityName, context.containingBoundaryName))
.scheduleActions(ActionGroupId, context);
}
});
// Entity in "other" filter bucket is no longer contained by any boundary and switches from "active" to "inactive"
if (containments[0].shapeLocationId === OTHER_CATEGORY) {
inactiveEntities.set(entityName, containments);
activeEntities.delete(entityName);
return;
}
const otherCatIndex = containments.findIndex(
({ shapeLocationId }) => shapeLocationId === OTHER_CATEGORY
);
if (otherCatIndex >= 0) {
const afterOtherLocationsArr = containments.slice(0, otherCatIndex);
activeEntities.set(entityName, afterOtherLocationsArr);
} else {
activeEntities.set(entityName, containments);
}
});
return { activeEntities, inactiveEntities };
}
export const getGeoContainmentExecutor = (): GeoContainmentAlertType['executor'] =>
async function ({
previousStartedAt: windowStart,
startedAt: windowEnd,
services,
params,
rule: { id: ruleId },
state,
logger,
}): Promise<{ state: GeoContainmentState }> {
const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters
? state
: await getShapesFilters(
params.boundaryIndexTitle,
params.boundaryGeoField,
params.geoField,
services.scopedClusterClient.asCurrentUser,
logger,
ruleId,
params.boundaryNameField,
params.boundaryIndexQuery
);
const executeEsQuery = await executeEsQueryFactory(
params,
services.scopedClusterClient.asCurrentUser,
logger,
shapesFilters
);
// Start collecting data only on the first cycle
let currentIntervalResults: estypes.SearchResponse<unknown> | undefined;
if (!windowStart) {
logger.debug(`alert ${GEO_CONTAINMENT_ID}:${ruleId} alert initialized. Collecting data`);
// Consider making first time window configurable?
const START_TIME_WINDOW = 1;
const tempPreviousEndTime = new Date(windowEnd);
tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - START_TIME_WINDOW);
currentIntervalResults = await executeEsQuery(tempPreviousEndTime, windowEnd);
} else {
currentIntervalResults = await executeEsQuery(windowStart, windowEnd);
}
const currLocationMap: Map<string, GeoContainmentInstanceState[]> = transformResults(
currentIntervalResults,
params.dateField,
params.geoField
);
const prevLocationMap: Map<string, GeoContainmentInstanceState[]> = new Map([
...Object.entries(
(state.prevLocationMap as Record<string, GeoContainmentInstanceState[]>) || {}
),
]);
const { activeEntities, inactiveEntities } = getEntitiesAndGenerateAlerts(
prevLocationMap,
currLocationMap,
services.alertFactory,
shapesIdsNamesMap,
windowEnd
);
const { getRecoveredAlerts } = services.alertFactory.done();
for (const recoveredAlert of getRecoveredAlerts()) {
const recoveredAlertId = recoveredAlert.getId();
try {
const context = getRecoveredAlertContext({
alertId: recoveredAlertId,
activeEntities,
inactiveEntities,
windowEnd,
});
if (context) {
recoveredAlert.setContext(context);
}
} catch (e) {
logger.warn(`Unable to set alert context for recovered alert, error: ${e.message}`);
}
}
return {
state: {
shapesFilters,
shapesIdsNamesMap,
prevLocationMap: Object.fromEntries(activeEntities),
},
};
};

View file

@ -5,31 +5,12 @@
* 2.0.
*/
import { AlertingSetup } from '../../types';
import {
GeoContainmentState,
GeoContainmentInstanceState,
GeoContainmentInstanceContext,
getAlertType,
ActionGroupId,
RecoveryActionGroupId,
} from './alert_type';
import type { RegisterRuleTypesParams } from '../types';
import { getRuleType } from './rule_type';
import { GeoContainmentExtractedParams, GeoContainmentParams } from './alert_type';
export { GEO_CONTAINMENT_ID } from './constants';
interface RegisterParams {
alerting: AlertingSetup;
}
export function register(params: RegisterParams) {
export function register(params: RegisterRuleTypesParams) {
const { alerting } = params;
alerting.registerType<
GeoContainmentParams,
GeoContainmentExtractedParams,
GeoContainmentState,
GeoContainmentInstanceState,
GeoContainmentInstanceContext,
typeof ActionGroupId,
typeof RecoveryActionGroupId
>(getAlertType());
alerting.registerType(getRuleType());
}

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { getContainedAlertContext, getRecoveredAlertContext } from '../get_context';
import { OTHER_CATEGORY } from '../es_query_builder';
import { getContainedAlertContext, getRecoveredAlertContext } from './alert_context';
import { OTHER_CATEGORY } from '../constants';
test('getContainedAlertContext', () => {
expect(

View file

@ -6,7 +6,10 @@
*/
import _ from 'lodash';
import { GeoContainmentInstanceContext, GeoContainmentInstanceState } from './alert_type';
import type {
GeoContainmentAlertInstanceContext,
GeoContainmentAlertInstanceState,
} from '../types';
export function getAlertId(entityName: string, boundaryName: unknown) {
return `${entityName}-${boundaryName}`;
@ -38,12 +41,12 @@ function getAlertContext({
isRecovered,
}: {
entityName: string;
containment: GeoContainmentInstanceState;
containment: GeoContainmentAlertInstanceState;
shapesIdsNamesMap?: Record<string, unknown>;
windowEnd: Date;
isRecovered: boolean;
}): GeoContainmentInstanceContext {
const context: GeoContainmentInstanceContext = {
}): GeoContainmentAlertInstanceContext {
const context: GeoContainmentAlertInstanceContext = {
entityId: entityName,
entityDateTime: containment.dateInShape || null,
entityDocumentId: containment.docId,
@ -61,10 +64,10 @@ function getAlertContext({
export function getContainedAlertContext(args: {
entityName: string;
containment: GeoContainmentInstanceState;
containment: GeoContainmentAlertInstanceState;
shapesIdsNamesMap: Record<string, unknown>;
windowEnd: Date;
}): GeoContainmentInstanceContext {
}): GeoContainmentAlertInstanceContext {
return getAlertContext({ ...args, isRecovered: false });
}
@ -75,16 +78,16 @@ export function getRecoveredAlertContext({
windowEnd,
}: {
alertId: string;
activeEntities: Map<string, GeoContainmentInstanceState[]>;
inactiveEntities: Map<string, GeoContainmentInstanceState[]>;
activeEntities: Map<string, GeoContainmentAlertInstanceState[]>;
inactiveEntities: Map<string, GeoContainmentAlertInstanceState[]>;
windowEnd: Date;
}): GeoContainmentInstanceContext | null {
}): GeoContainmentAlertInstanceContext | null {
const { entityName } = splitAlertId(alertId);
// recovered alert's latest entity location is either:
// 1) activeEntities - entity moved from one boundary to another boundary
// 2) inactiveEntities - entity moved from one boundary to outside all boundaries
let containment: GeoContainmentInstanceState | undefined;
let containment: GeoContainmentAlertInstanceState | undefined;
if (activeEntities.has(entityName) && activeEntities.get(entityName)?.length) {
containment = _.orderBy(activeEntities.get(entityName), ['dateInShape'], ['desc'])[0];
} else if (inactiveEntities.has(entityName) && inactiveEntities.get(entityName)?.length) {

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { ElasticsearchClient } from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { OTHER_CATEGORY } from '../constants';
import { getQueryDsl } from './get_query_dsl';
import type { GeoContainmentRuleParams } from '../types';
const MAX_BUCKETS_LIMIT = 65535;
export async function executeEsQuery(
params: GeoContainmentRuleParams,
esClient: ElasticsearchClient,
shapesFilters: Record<string, unknown>,
gteDateTime: Date | null,
ltDateTime: Date | null
): Promise<estypes.SearchResponse<unknown>> {
const { entity, index, dateField, geoField, indexQuery } = params;
let esFormattedQuery;
if (indexQuery) {
const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null;
const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null;
const dateRangeUpdatedQuery =
indexQuery.language === 'kuery'
? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})`
: `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`;
esFormattedQuery = getQueryDsl({
query: dateRangeUpdatedQuery,
language: indexQuery.language,
});
}
const esQuery = {
index,
body: {
size: 0, // do not fetch hits
aggs: {
shapes: {
filters: {
other_bucket_key: OTHER_CATEGORY,
filters: shapesFilters,
},
aggs: {
entitySplit: {
terms: {
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,
{
field: dateField,
format: 'strict_date_optional_time',
},
geoField,
],
_source: false,
},
},
},
},
},
},
},
query: esFormattedQuery
? esFormattedQuery
: {
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',
},
],
},
};
try {
return await esClient.search(esQuery);
} catch (err) {
throw new Error(
i18n.translate('xpack.stackAlerts.geoContainment.entityContainmentFetchError', {
defaultMessage: 'Unable to fetch entity containment, error: {error}',
values: { error: err.message },
})
);
}
}

View file

@ -0,0 +1,348 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import { alertsMock } from '@kbn/alerting-plugin/server/mocks';
import { getEntitiesAndGenerateAlerts } from './get_entities_and_generate_alerts';
import { OTHER_CATEGORY } from '../constants';
import type {
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
} from '../types';
const alertFactory = (contextKeys: unknown[], testAlertActionArr: unknown[]) => ({
create: (instanceId: string) => {
const alertInstance = alertsMock.createAlertFactory.create<
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext
>();
alertInstance.scheduleActions.mockImplementation(
(actionGroupId: string, context?: GeoContainmentAlertInstanceContext) => {
// Check subset of alert for comparison to expected results
// @ts-ignore
const contextSubset = _.pickBy(context, (v, k) => contextKeys.includes(k));
testAlertActionArr.push({
actionGroupId,
instanceId,
context: contextSubset,
});
}
);
return alertInstance;
},
alertLimit: {
getValue: () => 1000,
setLimitReached: () => {},
},
done: () => ({ getRecoveredAlerts: () => [] }),
});
describe('getEntitiesAndGenerateAlerts', () => {
const testAlertActionArr: unknown[] = [];
beforeEach(() => {
jest.clearAllMocks();
testAlertActionArr.length = 0;
});
const currLocationMap = new Map([
[
'a',
[
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
],
],
[
'b',
[
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 16 2020 15:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId2',
},
],
],
[
'c',
[
{
location: [0, 0],
shapeLocationId: '789',
dateInShape: 'Wed Dec 23 2020 16:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId3',
},
],
],
]);
const expectedAlertResults = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '123',
entityDocumentId: 'docId1',
entityId: 'a',
entityLocation: 'POINT (0 0)',
},
instanceId: 'a-123',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '456',
entityDocumentId: 'docId2',
entityId: 'b',
entityLocation: 'POINT (0 0)',
},
instanceId: 'b-456',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '789',
entityDocumentId: 'docId3',
entityId: 'c',
entityLocation: 'POINT (0 0)',
},
instanceId: 'c-789',
},
];
const contextKeys = Object.keys(expectedAlertResults[0].context);
const emptyShapesIdsNamesMap = {};
const currentDateTime = new Date();
test('should use currently active entities if no older entity entries', () => {
const emptyPrevLocationMap = new Map();
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should overwrite older identical entity entries', () => {
const prevLocationMapWithIdenticalEntityEntry = new Map([
[
'a',
[
{
location: [0, 0],
shapeLocationId: '999',
dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId7',
},
],
],
]);
const { activeEntities } = getEntitiesAndGenerateAlerts(
prevLocationMapWithIdenticalEntityEntry,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should preserve older non-identical entity entries', () => {
const prevLocationMapWithNonIdenticalEntityEntry = new Map([
[
'd',
[
{
location: [0, 0],
shapeLocationId: '999',
dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId7',
},
],
],
]);
const expectedAlertResultsPlusD = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '999',
entityDocumentId: 'docId7',
entityId: 'd',
entityLocation: 'POINT (0 0)',
},
instanceId: 'd-999',
},
...expectedAlertResults,
];
const { activeEntities } = getEntitiesAndGenerateAlerts(
prevLocationMapWithNonIdenticalEntityEntry,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).not.toEqual(currLocationMap);
expect(activeEntities.has('d')).toBeTruthy();
expect(testAlertActionArr).toMatchObject(expectedAlertResultsPlusD);
});
test('should remove "other" entries and schedule the expected number of actions', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
expect(currLocationMapWithOther).not.toEqual(currLocationMap);
const { activeEntities, inactiveEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(inactiveEntities).toEqual(
new Map([
[
'd',
[
{
location: [0, 0],
shapeLocationId: 'other',
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
],
],
])
);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
test('should generate multiple alerts per entity if found in multiple shapes in interval', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithThreeMore = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '789',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId2',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId3',
},
]);
getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithThreeMore,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
let numEntitiesInShapes = 0;
currLocationMapWithThreeMore.forEach((v) => {
numEntitiesInShapes += v.length;
});
expect(testAlertActionArr.length).toEqual(numEntitiesInShapes);
});
test('should not return entity as active entry if most recent location is "other"', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
expect(currLocationMapWithOther).not.toEqual(currLocationMap);
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
});
test('should return entity as active entry if "other" not the latest location but remove "other" and earlier entries', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(
new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
])
);
});
});

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RuleExecutorServices } from '@kbn/alerting-plugin/server';
import type {
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
} from '../types';
import { ActionGroupId, OTHER_CATEGORY } from '../constants';
import { getAlertId, getContainedAlertContext } from './alert_context';
export function getEntitiesAndGenerateAlerts(
prevLocationMap: Map<string, GeoContainmentAlertInstanceState[]>,
currLocationMap: Map<string, GeoContainmentAlertInstanceState[]>,
alertFactory: RuleExecutorServices<
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
typeof ActionGroupId
>['alertFactory'],
shapesIdsNamesMap: Record<string, unknown>,
windowEnd: Date
): {
activeEntities: Map<string, GeoContainmentAlertInstanceState[]>;
inactiveEntities: Map<string, GeoContainmentAlertInstanceState[]>;
} {
const activeEntities: Map<string, GeoContainmentAlertInstanceState[]> = new Map([
...prevLocationMap,
...currLocationMap,
]);
const inactiveEntities: Map<string, GeoContainmentAlertInstanceState[]> = new Map();
activeEntities.forEach((containments, entityName) => {
// Generate alerts
containments.forEach((containment) => {
if (containment.shapeLocationId !== OTHER_CATEGORY) {
const context = getContainedAlertContext({
entityName,
containment,
shapesIdsNamesMap,
windowEnd,
});
alertFactory
.create(getAlertId(entityName, context.containingBoundaryName))
.scheduleActions(ActionGroupId, context);
}
});
// Entity in "other" filter bucket is no longer contained by any boundary and switches from "active" to "inactive"
if (containments[0].shapeLocationId === OTHER_CATEGORY) {
inactiveEntities.set(entityName, containments);
activeEntities.delete(entityName);
return;
}
const otherCatIndex = containments.findIndex(
({ shapeLocationId }) => shapeLocationId === OTHER_CATEGORY
);
if (otherCatIndex >= 0) {
const afterOtherLocationsArr = containments.slice(0, otherCatIndex);
activeEntities.set(entityName, afterOtherLocationsArr);
} else {
activeEntities.set(entityName, containments);
}
});
return { activeEntities, inactiveEntities };
}

View file

@ -5,21 +5,21 @@
* 2.0.
*/
import { getEsFormattedQuery } from '../es_query_builder';
import { getQueryDsl } from './get_query_dsl';
describe('esFormattedQuery', () => {
it('lucene queries are converted correctly', async () => {
describe('getQueryDsl', () => {
test('should convert lucene queries to elasticsearch dsl', async () => {
const testLuceneQuery1 = {
query: `"airport": "Denver"`,
language: 'lucene',
};
const esFormattedQuery1 = getEsFormattedQuery(testLuceneQuery1);
const esFormattedQuery1 = getQueryDsl(testLuceneQuery1);
expect(esFormattedQuery1).toStrictEqual({ query_string: { query: '"airport": "Denver"' } });
const testLuceneQuery2 = {
query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`,
language: 'lucene',
};
const esFormattedQuery2 = getEsFormattedQuery(testLuceneQuery2);
const esFormattedQuery2 = getQueryDsl(testLuceneQuery2);
expect(esFormattedQuery2).toStrictEqual({
query_string: {
query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`,
@ -27,12 +27,12 @@ describe('esFormattedQuery', () => {
});
});
it('kuery queries are converted correctly', async () => {
test('should convert kuery queries to elasticsearch dsl', async () => {
const testKueryQuery1 = {
query: `"airport": "Denver"`,
language: 'kuery',
};
const esFormattedQuery1 = getEsFormattedQuery(testKueryQuery1);
const esFormattedQuery1 = getQueryDsl(testKueryQuery1);
expect(esFormattedQuery1).toStrictEqual({
bool: { minimum_should_match: 1, should: [{ match_phrase: { airport: 'Denver' } }] },
});
@ -40,7 +40,7 @@ describe('esFormattedQuery', () => {
query: `"airport": "Denver" and ("animal": "goat" or "animal": "narwhal")`,
language: 'kuery',
};
const esFormattedQuery2 = getEsFormattedQuery(testKueryQuery2);
const esFormattedQuery2 = getQueryDsl(testKueryQuery2);
expect(esFormattedQuery2).toStrictEqual({
bool: {
filter: [

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
fromKueryExpression,
toElasticsearchQuery,
luceneStringToDsl,
DataViewBase,
Query,
} from '@kbn/es-query';
export const getQueryDsl = (query: Query, indexPattern?: DataViewBase) => {
if (query.language === 'kuery') {
return toElasticsearchQuery(fromKueryExpression(query.query), indexPattern);
}
return luceneStringToDsl(query.query);
};

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { canSkipBoundariesFetch, getShapeFilters } from './get_shape_filters';
const boundariesRequestMeta = {
geoField: 'entityGeometry',
boundaryIndexTitle: 'boundaries',
boundaryGeoField: 'boundariesGeometry',
boundaryNameField: 'boundaryName',
boundaryIndexQuery: {
language: 'kuery',
query: 'iso2 : US',
},
};
describe('canSkipBoundariesFetch', () => {
test('should return false when previous request meta is undefined', () => {
expect(canSkipBoundariesFetch(boundariesRequestMeta, undefined)).toBe(false);
});
test('should return false when boundaries query changes', () => {
expect(
canSkipBoundariesFetch(
{
...boundariesRequestMeta,
boundaryIndexQuery: {
language: 'kuery',
query: 'iso2 : CA',
},
},
{ ...boundariesRequestMeta }
)
).toBe(false);
});
test('should return true when request meta is not changed', () => {
expect(canSkipBoundariesFetch(boundariesRequestMeta, { ...boundariesRequestMeta })).toBe(true);
});
});
describe('getShapeFilters', () => {
test('should return boundary filters', async () => {
const mockEsClient = {
search: () => {
return {
hits: {
hits: [
{
_index: 'boundaries',
_id: 'waFXH3kBi9P-_6qn8c8A',
fields: {
boundaryName: ['alpha'],
},
},
{
_index: 'boundaries',
_id: 'wqFXH3kBi9P-_6qn8c8A',
fields: {
boundaryName: ['bravo'],
},
},
{
_index: 'boundaries',
_id: 'w6FXH3kBi9P-_6qn8c8A',
fields: {
boundaryName: ['charlie'],
},
},
],
},
};
},
} as unknown as ElasticsearchClient;
const { shapesFilters, shapesIdsNamesMap } = await getShapeFilters(
boundariesRequestMeta,
mockEsClient
);
expect(shapesIdsNamesMap).toEqual({
'waFXH3kBi9P-_6qn8c8A': 'alpha',
'wqFXH3kBi9P-_6qn8c8A': 'bravo',
'w6FXH3kBi9P-_6qn8c8A': 'charlie',
});
expect(shapesFilters).toEqual({
'waFXH3kBi9P-_6qn8c8A': {
geo_shape: {
entityGeometry: {
indexed_shape: {
id: 'waFXH3kBi9P-_6qn8c8A',
index: 'boundaries',
path: 'boundariesGeometry',
},
},
},
},
'wqFXH3kBi9P-_6qn8c8A': {
geo_shape: {
entityGeometry: {
indexed_shape: {
id: 'wqFXH3kBi9P-_6qn8c8A',
index: 'boundaries',
path: 'boundariesGeometry',
},
},
},
},
'w6FXH3kBi9P-_6qn8c8A': {
geo_shape: {
entityGeometry: {
indexed_shape: {
id: 'w6FXH3kBi9P-_6qn8c8A',
index: 'boundaries',
path: 'boundariesGeometry',
},
},
},
},
});
});
test('should throw error when search throws', async () => {
const mockEsClient = {
search: () => {
throw new Error('Simulated elasticsearch search error');
},
} as unknown as ElasticsearchClient;
expect(async () => {
await getShapeFilters(boundariesRequestMeta, mockEsClient);
}).rejects.toThrow();
});
test('should throw error if no results found', async () => {
const mockEsClient = {
search: () => {
return {
hits: {
hits: [],
},
};
},
} as unknown as ElasticsearchClient;
expect(async () => {
await getShapeFilters(boundariesRequestMeta, mockEsClient);
}).rejects.toThrow();
});
});

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import fastIsEqual from 'fast-deep-equal';
import { i18n } from '@kbn/i18n';
import { ElasticsearchClient } from '@kbn/core/server';
import type { BoundariesRequestMeta } from '../types';
import { getQueryDsl } from './get_query_dsl';
interface BoundaryHit {
_index: string;
_id: string;
fields?: Record<string, unknown[]>;
}
// Consider dynamically obtaining from config?
const MAX_SHAPES_QUERY_SIZE = 10000;
export function canSkipBoundariesFetch(
requestMeta: BoundariesRequestMeta,
prevRequestMeta?: BoundariesRequestMeta
) {
return prevRequestMeta
? fastIsEqual(
[
requestMeta.geoField,
requestMeta.boundaryIndexTitle,
requestMeta.boundaryGeoField,
requestMeta.boundaryNameField,
requestMeta.boundaryIndexQuery,
],
[
prevRequestMeta.geoField,
prevRequestMeta.boundaryIndexTitle,
prevRequestMeta.boundaryGeoField,
prevRequestMeta.boundaryNameField,
prevRequestMeta.boundaryIndexQuery,
]
)
: false;
}
export async function getShapeFilters(
requestMeta: BoundariesRequestMeta,
esClient: ElasticsearchClient
) {
const { geoField, boundaryIndexTitle, boundaryGeoField, boundaryNameField, boundaryIndexQuery } =
requestMeta;
let boundaryData;
try {
boundaryData = await esClient.search<Record<string, BoundaryHit>>({
index: boundaryIndexTitle,
body: {
size: MAX_SHAPES_QUERY_SIZE,
_source: false,
fields: boundaryNameField ? [boundaryNameField] : [],
...(boundaryIndexQuery ? { query: getQueryDsl(boundaryIndexQuery) } : {}),
},
});
} catch (e) {
throw new Error(
i18n.translate('xpack.stackAlerts.geoContainment.boundariesFetchError', {
defaultMessage: 'Unable to fetch tracking containment boundaries, error: {error}',
values: { error: e.message },
})
);
}
const hits = boundaryData?.hits?.hits;
if (!hits || hits.length === 0) {
const noBoundariesMsg = i18n.translate('xpack.stackAlerts.geoContainment.noBoundariesError', {
defaultMessage:
'No tracking containtment boundaries found. Ensure index, "{index}", has documents.',
values: { index: boundaryIndexTitle },
});
const adjustQueryMsg = boundaryIndexQuery
? i18n.translate('xpack.stackAlerts.geoContainment.adjustQuery', {
defaultMessage: 'Adjust query, "{query}" to match documents.',
values: { query: boundaryIndexQuery.query as string },
})
: null;
throw new Error(adjustQueryMsg ? `${noBoundariesMsg} ${adjustQueryMsg}` : noBoundariesMsg);
}
const filters: Record<string, unknown> = {};
const shapesIdsNamesMap: Record<string, unknown> = {};
for (let i = 0; i < hits.length; i++) {
const boundaryHit: BoundaryHit = hits[i];
filters[boundaryHit._id] = {
geo_shape: {
[geoField]: {
indexed_shape: {
index: boundaryHit._index,
id: boundaryHit._id,
path: boundaryGeoField,
},
},
},
};
if (
boundaryNameField &&
boundaryHit.fields &&
boundaryHit.fields[boundaryNameField] &&
boundaryHit.fields[boundaryNameField].length
) {
// fields API always returns an array, grab first value
shapesIdsNamesMap[boundaryHit._id] = boundaryHit.fields[boundaryNameField][0];
}
}
return {
shapesFilters: filters,
shapesIdsNamesMap,
};
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getRecoveredAlertContext } from './alert_context';
export { executeEsQuery } from './es_query_builder';
export { getEntitiesAndGenerateAlerts } from './get_entities_and_generate_alerts';
export { canSkipBoundariesFetch, getShapeFilters } from './get_shape_filters';
export { transformResults } from './transform_results';

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import sampleAggsJsonResponse from '../tests/es_sample_response.json';
import sampleAggsJsonResponseWithNesting from '../tests/es_sample_response_with_nesting.json';
import { transformResults } from './transform_results';
describe('transformResults', () => {
const dateField = '@timestamp';
const geoField = 'location';
test('should correctly transform expected results', async () => {
const transformedResults = transformResults(
// @ts-ignore
sampleAggsJsonResponse.body,
dateField,
geoField
);
expect(transformedResults).toEqual(
new Map([
[
'0',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZVBoGXkBsFLYN2Tj1wmV',
location: [-73.99018926545978, 40.751759740523994],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
{
dateInShape: '2021-04-28T16:56:01.896Z',
docId: 'YlBoGXkBsFLYN2TjsAlp',
location: [-73.98968475870788, 40.7506317878142],
shapeLocationId: 'other',
},
],
],
[
'1',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZlBoGXkBsFLYN2Tj1wmV',
location: [-73.99561604484916, 40.75449890457094],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
{
dateInShape: '2021-04-28T16:56:01.896Z',
docId: 'Y1BoGXkBsFLYN2TjsAlp',
location: [-73.99459345266223, 40.755913141183555],
shapeLocationId: 'other',
},
],
],
[
'2',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'Z1BoGXkBsFLYN2Tj1wmV',
location: [-73.98662586696446, 40.7667087810114],
shapeLocationId: 'other',
},
],
],
])
);
});
const nestedDateField = 'time_data.@timestamp';
const nestedGeoField = 'geo.coords.location';
test('should correctly transform expected results if fields are nested', async () => {
const transformedResults = transformResults(
// @ts-ignore
sampleAggsJsonResponseWithNesting.body,
nestedDateField,
nestedGeoField
);
expect(transformedResults).toEqual(
new Map([
[
'936',
[
{
dateInShape: '2020-09-28T18:01:41.190Z',
docId: 'N-ng1XQB6yyY-xQxnGSM',
location: [-82.8814151789993, 40.62806099653244],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'AAL2019',
[
{
dateInShape: '2020-09-28T18:01:41.191Z',
docId: 'iOng1XQB6yyY-xQxnGSM',
location: [-82.22068064846098, 39.006176185794175],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'AAL2323',
[
{
dateInShape: '2020-09-28T18:01:41.191Z',
docId: 'n-ng1XQB6yyY-xQxnGSM',
location: [-84.71324851736426, 41.6677269525826],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'ABD5250',
[
{
dateInShape: '2020-09-28T18:01:41.192Z',
docId: 'GOng1XQB6yyY-xQxnGWM',
location: [6.073727197945118, 39.07997465226799],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
])
);
});
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { GeoContainmentAlertInstanceState } from '../types';
// Flatten agg results and get latest locations for each entity
export function transformResults(
results: estypes.SearchResponse<unknown>,
dateField: string,
geoField: string
): Map<string, GeoContainmentAlertInstanceState[]> {
const buckets = _.get(results, 'aggregations.shapes.buckets', {});
const arrResults = _.flatMap(buckets, (bucket: unknown, bucketKey: string) => {
const subBuckets = _.get(bucket, 'entitySplit.buckets', []);
return _.map(subBuckets, (subBucket) => {
const locationFieldResult = _.get(
subBucket,
`entityHits.hits.hits[0].fields["${geoField}"][0]`,
''
);
const location = locationFieldResult
? _.chain(locationFieldResult)
.split(', ')
.map((coordString) => +coordString)
.reverse()
.value()
: [];
const dateInShape = _.get(
subBucket,
`entityHits.hits.hits[0].fields["${dateField}"][0]`,
null
);
const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`);
return {
location,
shapeLocationId: bucketKey,
entityName: subBucket.key,
dateInShape,
docId,
};
});
});
const orderedResults = _.orderBy(arrResults, ['entityName', 'dateInShape'], ['asc', 'desc'])
// Get unique
.reduce(
(
accu: Map<string, GeoContainmentAlertInstanceState[]>,
el: GeoContainmentAlertInstanceState & { entityName: string }
) => {
const { entityName, ...locationData } = el;
if (entityName) {
if (!accu.has(entityName)) {
accu.set(entityName, []);
}
accu.get(entityName)!.push(locationData);
}
return accu;
},
new Map()
);
return orderedResults;
}

View file

@ -6,31 +6,31 @@
*/
import {
getAlertType,
getRuleType,
injectEntityAndBoundaryIds,
GeoContainmentParams,
extractEntityAndBoundaryReferences,
} from '../alert_type';
} from './rule_type';
import type { GeoContainmentRuleParams } from './types';
describe('alertType', () => {
const alertType = getAlertType();
describe('ruleType', () => {
const ruleType = getRuleType();
it('alert type creation structure is the expected value', async () => {
expect(alertType.id).toBe('.geo-containment');
expect(alertType.name).toBe('Tracking containment');
expect(alertType.actionGroups).toEqual([
expect(ruleType.id).toBe('.geo-containment');
expect(ruleType.name).toBe('Tracking containment');
expect(ruleType.actionGroups).toEqual([
{ id: 'Tracked entity contained', name: 'Tracking containment met' },
]);
expect(alertType.recoveryActionGroup).toEqual({
expect(ruleType.recoveryActionGroup).toEqual({
id: 'notGeoContained',
name: 'No longer contained',
});
expect(alertType.actionVariables).toMatchSnapshot();
expect(ruleType.actionVariables).toMatchSnapshot();
});
it('validator succeeds with valid params', async () => {
const params: GeoContainmentParams = {
const params: GeoContainmentRuleParams = {
index: 'testIndex',
indexId: 'testIndexId',
geoField: 'testField',
@ -43,7 +43,7 @@ describe('alertType', () => {
boundaryNameField: 'testField',
};
expect(alertType.validate?.params?.validate(params)).toBeTruthy();
expect(ruleType.validate?.params?.validate(params)).toBeTruthy();
});
test('injectEntityAndBoundaryIds', () => {

View file

@ -8,43 +8,15 @@
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { SavedObjectReference } from '@kbn/core/server';
import {
RuleType,
RuleTypeState,
AlertInstanceState,
AlertInstanceContext,
RuleParamsAndRefs,
RuleTypeParams,
} from '@kbn/alerting-plugin/server';
import { Query } from '@kbn/data-plugin/common/query';
import { RuleParamsAndRefs } from '@kbn/alerting-plugin/server';
import { STACK_ALERTS_FEATURE_ID } from '../../../common';
import { getGeoContainmentExecutor } from './geo_containment';
export const ActionGroupId = 'Tracked entity contained';
export const RecoveryActionGroupId = 'notGeoContained';
export const GEO_CONTAINMENT_ID = '.geo-containment';
export interface GeoContainmentParams extends RuleTypeParams {
index: string;
indexId: string;
geoField: string;
entity: string;
dateField: string;
boundaryType: string;
boundaryIndexTitle: string;
boundaryIndexId: string;
boundaryGeoField: string;
boundaryNameField?: string;
indexQuery?: Query;
boundaryIndexQuery?: Query;
}
export type GeoContainmentExtractedParams = Omit<
GeoContainmentParams,
'indexId' | 'boundaryIndexId'
> & {
indexRefName: string;
boundaryIndexRefName: string;
};
import type {
GeoContainmentRuleType,
GeoContainmentExtractedRuleParams,
GeoContainmentRuleParams,
} from './types';
import { executor } from './executor';
import { ActionGroupId, RecoveryActionGroupId, GEO_CONTAINMENT_ID } from './constants';
const actionVariables = {
context: [
@ -132,40 +104,8 @@ export const ParamsSchema = schema.object({
boundaryIndexQuery: schema.maybe(schema.any({})),
});
export interface GeoContainmentState extends RuleTypeState {
shapesFilters: Record<string, unknown>;
shapesIdsNamesMap: Record<string, unknown>;
prevLocationMap: Record<string, unknown>;
}
export interface GeoContainmentInstanceState extends AlertInstanceState {
location: number[];
shapeLocationId: string;
dateInShape: string | null;
docId: string;
}
export interface GeoContainmentInstanceContext extends AlertInstanceContext {
entityId: string;
entityDateTime: string | null;
entityDocumentId: string;
detectionDateTime: string;
entityLocation: string;
// recovered alerts are not contained in boundary so context does not include boundary state
containingBoundaryId?: string;
containingBoundaryName?: unknown;
}
export type GeoContainmentAlertType = RuleType<
GeoContainmentParams,
GeoContainmentExtractedParams,
GeoContainmentState,
GeoContainmentInstanceState,
GeoContainmentInstanceContext,
typeof ActionGroupId,
typeof RecoveryActionGroupId
>;
export function extractEntityAndBoundaryReferences(params: GeoContainmentParams): {
params: GeoContainmentExtractedParams;
export function extractEntityAndBoundaryReferences(params: GeoContainmentRuleParams): {
params: GeoContainmentExtractedRuleParams;
references: SavedObjectReference[];
} {
const { indexId, boundaryIndexId, ...otherParams } = params;
@ -194,9 +134,9 @@ export function extractEntityAndBoundaryReferences(params: GeoContainmentParams)
}
export function injectEntityAndBoundaryIds(
params: GeoContainmentExtractedParams,
params: GeoContainmentExtractedRuleParams,
references: SavedObjectReference[]
): GeoContainmentParams {
): GeoContainmentRuleParams {
const { indexRefName, boundaryIndexRefName, ...otherParams } = params;
const { id: indexId = null } = references.find((ref) => ref.name === indexRefName) || {};
const { id: boundaryIndexId = null } =
@ -211,10 +151,10 @@ export function injectEntityAndBoundaryIds(
...otherParams,
indexId,
boundaryIndexId,
} as GeoContainmentParams;
} as GeoContainmentRuleParams;
}
export function getAlertType(): GeoContainmentAlertType {
export function getRuleType(): GeoContainmentRuleType {
const alertTypeName = i18n.translate('xpack.stackAlerts.geoContainment.alertTypeTitle', {
defaultMessage: 'Tracking containment',
});
@ -238,7 +178,7 @@ export function getAlertType(): GeoContainmentAlertType {
},
doesSetRecoveryContext: true,
defaultActionGroupId: ActionGroupId,
executor: getGeoContainmentExecutor(),
executor,
producer: STACK_ALERTS_FEATURE_ID,
validate: {
params: ParamsSchema,
@ -248,12 +188,12 @@ export function getAlertType(): GeoContainmentAlertType {
isExportable: true,
useSavedObjectReferences: {
extractReferences: (
params: GeoContainmentParams
): RuleParamsAndRefs<GeoContainmentExtractedParams> => {
params: GeoContainmentRuleParams
): RuleParamsAndRefs<GeoContainmentExtractedRuleParams> => {
return extractEntityAndBoundaryReferences(params);
},
injectReferences: (
params: GeoContainmentExtractedParams,
params: GeoContainmentExtractedRuleParams,
references: SavedObjectReference[]
) => {
return injectEntityAndBoundaryIds(params, references);

View file

@ -18,408 +18,57 @@
{
"_index":"manhattan_boundaries",
"_id":"waFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.96772861480713,
40.76060200607076
],
[
-73.96805047988892,
40.7601631739201
],
[
-73.96732091903687,
40.7598706175435
],
[
-73.96693468093872,
40.760471982031845
],
[
-73.96759986877441,
40.76078078870895
],
[
-73.96772861480713,
40.76060200607076
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"wqFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.97641897201538,
40.75618104606283
],
[
-73.97865056991577,
40.75371038152863
],
[
-73.97770643234252,
40.75323899431278
],
[
-73.9788007736206,
40.75187357799962
],
[
-73.97671937942503,
40.751060816881505
],
[
-73.97500276565552,
40.75377540019266
],
[
-73.97639751434325,
40.75460438258571
],
[
-73.9755392074585,
40.755985996937774
],
[
-73.97641897201538,
40.75618104606283
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"w6FXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.98592472076415,
40.75957805987928
],
[
-73.98695468902588,
40.75566091379097
],
[
-73.98573160171509,
40.75553088008716
],
[
-73.98465871810913,
40.75946428710659
],
[
-73.98592472076415,
40.75957805987928
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"xKFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.9894437789917,
40.75161349552273
],
[
-73.98914337158203,
40.75206863918968
],
[
-73.99575233459473,
40.75486445336327
],
[
-73.99819850921631,
40.75148345390278
],
[
-73.9914608001709,
40.74881754464601
],
[
-73.9894437789917,
40.75161349552273
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"xaFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.96914482116699,
40.75874913950493
],
[
-73.96946668624878,
40.758229027325804
],
[
-73.96856546401978,
40.75793646243674
],
[
-73.96824359893799,
40.75845657690492
],
[
-73.96914482116699,
40.75874913950493
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"xqFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.96953105926514,
40.7581640130173
],
[
-73.9699387550354,
40.75749761268889
],
[
-73.96923065185547,
40.75728631362887
],
[
-73.96862983703613,
40.757920208794026
],
[
-73.96953105926514,
40.7581640130173
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"x6FXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.97045373916626,
40.75679869785023
],
[
-73.97079706192015,
40.75629482445485
],
[
-73.96998167037964,
40.756051013376364
],
[
-73.96961688995361,
40.756554888619675
],
[
-73.97045373916626,
40.75679869785023
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"yKFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.98412227630615,
40.75479943576424
],
[
-73.98498058319092,
40.75351532515499
],
[
-73.98191213607788,
40.75219867966512
],
[
-73.9808177947998,
40.75340154200611
],
[
-73.98412227630615,
40.75479943576424
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"yaFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.99725437164307,
40.74498104863726
],
[
-74.00386333465576,
40.736136757139285
],
[
-73.99703979492188,
40.73334015558748
],
[
-73.9897871017456,
40.74153451605774
],
[
-73.99725437164307,
40.74498104863726
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"yqFXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.98830652236938,
40.75075196505171
],
[
-73.9885640144348,
40.74759834321152
],
[
-73.98761987686157,
40.747582087041366
],
[
-73.98751258850098,
40.74816730666263
],
[
-73.98807048797607,
40.74826484276548
],
[
-73.9875340461731,
40.75075196505171
],
[
-73.98830652236938,
40.75075196505171
]
]
]
}
}
"_score":1
},
{
"_index":"manhattan_boundaries",
"_id":"y6FXH3kBi9P-_6qn8c8A",
"_score":1,
"_source":{
"coordinates":{
"type":"Polygon",
"coordinates":[
[
[
-73.9824914932251,
40.7467692734681
],
[
-73.98356437683105,
40.7452411570555
],
[
-73.9813756942749,
40.74446082874893
],
[
-73.98030281066895,
40.745696344339564
],
[
-73.9824914932251,
40.7467692734681
]
]
]
}
}
"_score":1
}
]
}

View file

@ -1,666 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { RuleExecutorServicesMock, alertsMock } from '@kbn/alerting-plugin/server/mocks';
import sampleAggsJsonResponse from './es_sample_response.json';
import sampleShapesJsonResponse from './es_sample_response_shapes.json';
import sampleAggsJsonResponseWithNesting from './es_sample_response_with_nesting.json';
import {
getEntitiesAndGenerateAlerts,
transformResults,
getGeoContainmentExecutor,
} from '../geo_containment';
import { OTHER_CATEGORY } from '../es_query_builder';
import { GeoContainmentInstanceContext, GeoContainmentInstanceState } from '../alert_type';
import type { GeoContainmentParams } from '../alert_type';
const alertFactory = (contextKeys: unknown[], testAlertActionArr: unknown[]) => ({
create: (instanceId: string) => {
const alertInstance = alertsMock.createAlertFactory.create<
GeoContainmentInstanceState,
GeoContainmentInstanceContext
>();
alertInstance.scheduleActions.mockImplementation(
(actionGroupId: string, context?: GeoContainmentInstanceContext) => {
// Check subset of alert for comparison to expected results
// @ts-ignore
const contextSubset = _.pickBy(context, (v, k) => contextKeys.includes(k));
testAlertActionArr.push({
actionGroupId,
instanceId,
context: contextSubset,
});
}
);
return alertInstance;
},
alertLimit: {
getValue: () => 1000,
setLimitReached: () => {},
},
done: () => ({ getRecoveredAlerts: () => [] }),
});
describe('geo_containment', () => {
describe('transformResults', () => {
const dateField = '@timestamp';
const geoField = 'location';
it('should correctly transform expected results', async () => {
const transformedResults = transformResults(
// @ts-ignore
sampleAggsJsonResponse.body,
dateField,
geoField
);
expect(transformedResults).toEqual(
new Map([
[
'0',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZVBoGXkBsFLYN2Tj1wmV',
location: [-73.99018926545978, 40.751759740523994],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
{
dateInShape: '2021-04-28T16:56:01.896Z',
docId: 'YlBoGXkBsFLYN2TjsAlp',
location: [-73.98968475870788, 40.7506317878142],
shapeLocationId: 'other',
},
],
],
[
'1',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZlBoGXkBsFLYN2Tj1wmV',
location: [-73.99561604484916, 40.75449890457094],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
{
dateInShape: '2021-04-28T16:56:01.896Z',
docId: 'Y1BoGXkBsFLYN2TjsAlp',
location: [-73.99459345266223, 40.755913141183555],
shapeLocationId: 'other',
},
],
],
[
'2',
[
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'Z1BoGXkBsFLYN2Tj1wmV',
location: [-73.98662586696446, 40.7667087810114],
shapeLocationId: 'other',
},
],
],
])
);
});
const nestedDateField = 'time_data.@timestamp';
const nestedGeoField = 'geo.coords.location';
it('should correctly transform expected results if fields are nested', async () => {
const transformedResults = transformResults(
// @ts-ignore
sampleAggsJsonResponseWithNesting.body,
nestedDateField,
nestedGeoField
);
expect(transformedResults).toEqual(
new Map([
[
'936',
[
{
dateInShape: '2020-09-28T18:01:41.190Z',
docId: 'N-ng1XQB6yyY-xQxnGSM',
location: [-82.8814151789993, 40.62806099653244],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'AAL2019',
[
{
dateInShape: '2020-09-28T18:01:41.191Z',
docId: 'iOng1XQB6yyY-xQxnGSM',
location: [-82.22068064846098, 39.006176185794175],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'AAL2323',
[
{
dateInShape: '2020-09-28T18:01:41.191Z',
docId: 'n-ng1XQB6yyY-xQxnGSM',
location: [-84.71324851736426, 41.6677269525826],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
[
'ABD5250',
[
{
dateInShape: '2020-09-28T18:01:41.192Z',
docId: 'GOng1XQB6yyY-xQxnGWM',
location: [6.073727197945118, 39.07997465226799],
shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip',
},
],
],
])
);
});
it('should return an empty array if no results', async () => {
const transformedResults = transformResults(undefined, dateField, geoField);
expect(transformedResults).toEqual(new Map());
});
});
describe('getEntitiesAndGenerateAlerts', () => {
const testAlertActionArr: unknown[] = [];
beforeEach(() => {
jest.clearAllMocks();
testAlertActionArr.length = 0;
});
const currLocationMap = new Map([
[
'a',
[
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
],
],
[
'b',
[
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 16 2020 15:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId2',
},
],
],
[
'c',
[
{
location: [0, 0],
shapeLocationId: '789',
dateInShape: 'Wed Dec 23 2020 16:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId3',
},
],
],
]);
const expectedAlertResults = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '123',
entityDocumentId: 'docId1',
entityId: 'a',
entityLocation: 'POINT (0 0)',
},
instanceId: 'a-123',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '456',
entityDocumentId: 'docId2',
entityId: 'b',
entityLocation: 'POINT (0 0)',
},
instanceId: 'b-456',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '789',
entityDocumentId: 'docId3',
entityId: 'c',
entityLocation: 'POINT (0 0)',
},
instanceId: 'c-789',
},
];
const contextKeys = Object.keys(expectedAlertResults[0].context);
const emptyShapesIdsNamesMap = {};
const currentDateTime = new Date();
it('should use currently active entities if no older entity entries', () => {
const emptyPrevLocationMap = new Map();
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should overwrite older identical entity entries', () => {
const prevLocationMapWithIdenticalEntityEntry = new Map([
[
'a',
[
{
location: [0, 0],
shapeLocationId: '999',
dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId7',
},
],
],
]);
const { activeEntities } = getEntitiesAndGenerateAlerts(
prevLocationMapWithIdenticalEntityEntry,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should preserve older non-identical entity entries', () => {
const prevLocationMapWithNonIdenticalEntityEntry = new Map([
[
'd',
[
{
location: [0, 0],
shapeLocationId: '999',
dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId7',
},
],
],
]);
const expectedAlertResultsPlusD = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: '999',
entityDocumentId: 'docId7',
entityId: 'd',
entityLocation: 'POINT (0 0)',
},
instanceId: 'd-999',
},
...expectedAlertResults,
];
const { activeEntities } = getEntitiesAndGenerateAlerts(
prevLocationMapWithNonIdenticalEntityEntry,
currLocationMap,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).not.toEqual(currLocationMap);
expect(activeEntities.has('d')).toBeTruthy();
expect(testAlertActionArr).toMatchObject(expectedAlertResultsPlusD);
});
it('should remove "other" entries and schedule the expected number of actions', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
expect(currLocationMapWithOther).not.toEqual(currLocationMap);
const { activeEntities, inactiveEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
expect(inactiveEntities).toEqual(
new Map([
[
'd',
[
{
location: [0, 0],
shapeLocationId: 'other',
dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
],
],
])
);
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should generate multiple alerts per entity if found in multiple shapes in interval', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithThreeMore = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '789',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId2',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId3',
},
]);
getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithThreeMore,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
let numEntitiesInShapes = 0;
currLocationMapWithThreeMore.forEach((v) => {
numEntitiesInShapes += v.length;
});
expect(testAlertActionArr.length).toEqual(numEntitiesInShapes);
});
it('should not return entity as active entry if most recent location is "other"', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
expect(currLocationMapWithOther).not.toEqual(currLocationMap);
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(currLocationMap);
});
it('should return entity as active entry if "other" not the latest location but remove "other" and earlier entries', () => {
const emptyPrevLocationMap = new Map();
const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: OTHER_CATEGORY,
dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
{
location: [0, 0],
shapeLocationId: '456',
dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
]);
const { activeEntities } = getEntitiesAndGenerateAlerts(
emptyPrevLocationMap,
currLocationMapWithOther,
alertFactory(contextKeys, testAlertActionArr),
emptyShapesIdsNamesMap,
currentDateTime
);
expect(activeEntities).toEqual(
new Map([...currLocationMap]).set('d', [
{
location: [0, 0],
shapeLocationId: '123',
dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)',
docId: 'docId1',
},
])
);
});
});
describe('getGeoContainmentExecutor', () => {
// Params needed for all tests
const expectedAlertResults = [
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: 'kFATGXkBsFLYN2Tj6AAk',
entityDocumentId: 'ZVBoGXkBsFLYN2Tj1wmV',
entityId: '0',
entityLocation: 'POINT (-73.99018926545978 40.751759740523994)',
},
instanceId: '0-kFATGXkBsFLYN2Tj6AAk',
},
{
actionGroupId: 'Tracked entity contained',
context: {
containingBoundaryId: 'kFATGXkBsFLYN2Tj6AAk',
entityDocumentId: 'ZlBoGXkBsFLYN2Tj1wmV',
entityId: '1',
entityLocation: 'POINT (-73.99561604484916 40.75449890457094)',
},
instanceId: '1-kFATGXkBsFLYN2Tj6AAk',
},
];
const testAlertActionArr: unknown[] = [];
const previousStartedAt = new Date('2021-04-27T16:56:11.923Z');
const startedAt = new Date('2021-04-29T16:56:11.923Z');
const geoContainmentParams: GeoContainmentParams = {
index: 'testIndex',
indexId: 'testIndexId',
geoField: 'location',
entity: 'testEntity',
dateField: '@timestamp',
boundaryType: 'testBoundaryType',
boundaryIndexTitle: 'testBoundaryIndexTitle',
boundaryIndexId: 'testBoundaryIndexId',
boundaryGeoField: 'testBoundaryGeoField',
};
const ruleId = 'testAlertId';
const geoContainmentState = {
shapesFilters: {
testShape: 'thisIsAShape',
},
shapesIdsNamesMap: {},
prevLocationMap: {},
};
// Boundary test mocks
const boundaryCall = jest.fn();
const esAggCall = jest.fn();
const contextKeys = Object.keys(expectedAlertResults[0].context);
const esClient = elasticsearchServiceMock.createElasticsearchClient();
// @ts-ignore incomplete return type
esClient.search.mockResponseImplementation(({ index }) => {
if (index === geoContainmentParams.boundaryIndexTitle) {
boundaryCall();
return sampleShapesJsonResponse;
} else {
esAggCall();
return sampleAggsJsonResponse;
}
});
const alertServicesWithSearchMock: RuleExecutorServicesMock = {
...alertsMock.createRuleExecutorServices(),
// @ts-ignore
alertFactory: alertFactory(contextKeys, testAlertActionArr),
// @ts-ignore
scopedClusterClient: {
asCurrentUser: esClient,
},
};
beforeEach(() => {
jest.clearAllMocks();
testAlertActionArr.length = 0;
});
it('should query for shapes if state does not contain shapes', async () => {
const executor = await getGeoContainmentExecutor();
// @ts-ignore
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
// @ts-ignore
state: {},
});
if (executionResult && executionResult.state.shapesFilters) {
expect(boundaryCall.mock.calls.length).toBe(1);
expect(esAggCall.mock.calls.length).toBe(1);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should not query for shapes if state contains shapes', async () => {
const executor = await getGeoContainmentExecutor();
// @ts-ignore
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.shapesFilters) {
expect(boundaryCall.mock.calls.length).toBe(0);
expect(esAggCall.mock.calls.length).toBe(1);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should carry through shapes filters in state to next call unmodified', async () => {
const executor = await getGeoContainmentExecutor();
// @ts-ignore
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.shapesFilters) {
expect(executionResult.state.shapesFilters).toEqual(geoContainmentState.shapesFilters);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
it('should return previous locations map', async () => {
const expectedPrevLocationMap = {
'0': [
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZVBoGXkBsFLYN2Tj1wmV',
location: [-73.99018926545978, 40.751759740523994],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
],
'1': [
{
dateInShape: '2021-04-28T16:56:11.923Z',
docId: 'ZlBoGXkBsFLYN2Tj1wmV',
location: [-73.99561604484916, 40.75449890457094],
shapeLocationId: 'kFATGXkBsFLYN2Tj6AAk',
},
],
};
const executor = await getGeoContainmentExecutor();
// @ts-ignore
const executionResult = await executor({
previousStartedAt,
startedAt,
// @ts-ignore
services: alertServicesWithSearchMock,
params: geoContainmentParams,
// @ts-ignore
rule: {
id: ruleId,
},
state: geoContainmentState,
});
if (executionResult && executionResult.state.prevLocationMap) {
expect(executionResult.state.prevLocationMap).toEqual(expectedPrevLocationMap);
}
expect(testAlertActionArr).toMatchObject(expectedAlertResults);
});
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Query } from '@kbn/data-plugin/common/query';
import {
RuleType,
RuleTypeState,
AlertInstanceState,
AlertInstanceContext,
RuleTypeParams,
} from '@kbn/alerting-plugin/server';
import { ActionGroupId, RecoveryActionGroupId } from './constants';
export interface BoundariesRequestMeta {
geoField: string;
boundaryIndexTitle: string;
boundaryGeoField: string;
boundaryNameField?: string;
boundaryIndexQuery?: Query;
}
export interface GeoContainmentRuleParams extends RuleTypeParams {
index: string;
indexId: string;
geoField: string;
entity: string;
dateField: string;
boundaryType: string;
boundaryIndexTitle: string;
boundaryIndexId: string;
boundaryGeoField: string;
boundaryNameField?: string;
indexQuery?: Query;
boundaryIndexQuery?: Query;
}
export type GeoContainmentExtractedRuleParams = Omit<
GeoContainmentRuleParams,
'indexId' | 'boundaryIndexId'
> & {
indexRefName: string;
boundaryIndexRefName: string;
};
export interface GeoContainmentRuleState extends RuleTypeState {
boundariesRequestMeta?: BoundariesRequestMeta;
shapesFilters: Record<string, unknown>;
shapesIdsNamesMap: Record<string, unknown>;
prevLocationMap: Record<string, unknown>;
}
export interface GeoContainmentAlertInstanceState extends AlertInstanceState {
location: number[];
shapeLocationId: string;
dateInShape: string | null;
docId: string;
}
export interface GeoContainmentAlertInstanceContext extends AlertInstanceContext {
entityId: string;
entityDateTime: string | null;
entityDocumentId: string;
detectionDateTime: string;
entityLocation: string;
// recovered alerts are not contained in boundary so context does not include boundary state
containingBoundaryId?: string;
containingBoundaryName?: unknown;
}
export type GeoContainmentRuleType = RuleType<
GeoContainmentRuleParams,
GeoContainmentExtractedRuleParams,
GeoContainmentRuleState,
GeoContainmentAlertInstanceState,
GeoContainmentAlertInstanceContext,
typeof ActionGroupId,
typeof RecoveryActionGroupId
>;

View file

@ -5,17 +5,10 @@
* 2.0.
*/
import { CoreSetup, Logger } from '@kbn/core/server';
import { AlertingSetup, StackAlertsStartDeps } from '../types';
import type { RegisterRuleTypesParams } from './types';
import { register as registerIndexThreshold } from './index_threshold';
import { register as registerGeoContainment } from './geo_containment';
import { register as registerEsQuery } from './es_query';
interface RegisterRuleTypesParams {
logger: Logger;
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
alerting: AlertingSetup;
core: CoreSetup;
}
export function registerBuiltInRuleTypes(params: RegisterRuleTypesParams) {
registerIndexThreshold(params);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AlertingSetup, StackAlertsStartDeps } from '../../types';
import type { RegisterRuleTypesParams } from '../types';
import { getRuleType } from './rule_type';
// future enhancement: make these configurable?
@ -13,12 +13,7 @@ export const MAX_INTERVALS = 1000;
export const MAX_GROUPS = 1000;
export const DEFAULT_GROUPS = 100;
interface RegisterParams {
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
alerting: AlertingSetup;
}
export function register(params: RegisterParams) {
export function register(params: RegisterRuleTypesParams) {
const { data, alerting } = params;
alerting.registerType(getRuleType(data));
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreSetup, Logger } from '@kbn/core/server';
import { AlertingSetup, StackAlertsStartDeps } from '../types';
export interface RegisterRuleTypesParams {
logger: Logger;
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
alerting: AlertingSetup;
core: CoreSetup;
}