[Synthetics] Improve synthetics alerting (#186585)

## Summary

Fixes https://github.com/elastic/kibana/issues/175298

Improve synthetics alerting !!

User will be able to create custom synthetics status alert by defining
three kind of criteria

### Monitor is down over last consective checks with threshold

<img width="639" alt="image"
src="390da238-f7f2-4eb0-9606-3279b3199fdf">

### From Locations threshold

Will be considered down only when from defined number of locations

<img width="618" alt="image"
src="24741a10-0880-4247-9048-8ce03df25bf5">


### Over time with checks threshold just like uptime custom status alert

<img width="631" alt="image"
src="64e1c808-8d4b-4dd0-b794-eb7f4e5d1e6b">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dominique Clarke <dominique.clarke@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Maryam Saeidi <maryam.saeidi@elastic.co>
Co-authored-by: Justin Kambic <jk@elastic.co>
This commit is contained in:
Shahzad 2024-10-01 18:48:39 +02:00 committed by GitHub
parent 9e117c3aa2
commit 82d0b008cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 6148 additions and 1301 deletions

1
.github/CODEOWNERS vendored
View file

@ -656,6 +656,7 @@ x-pack/plugins/observability_solution/observability_onboarding/e2e @elastic/obs-
x-pack/plugins/observability_solution/observability_onboarding @elastic/obs-ux-logs-team @elastic/obs-ux-onboarding-team
x-pack/plugins/observability_solution/observability @elastic/obs-ux-management-team
x-pack/plugins/observability_solution/observability_shared @elastic/observability-ui
x-pack/packages/observability/synthetics_test_data @elastic/obs-ux-management-team
x-pack/packages/observability/observability_utils @elastic/observability-ui
x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security
test/common/plugins/otel_metrics @elastic/obs-ux-infra_services-team

View file

@ -689,6 +689,7 @@
"@kbn/observability-onboarding-plugin": "link:x-pack/plugins/observability_solution/observability_onboarding",
"@kbn/observability-plugin": "link:x-pack/plugins/observability_solution/observability",
"@kbn/observability-shared-plugin": "link:x-pack/plugins/observability_solution/observability_shared",
"@kbn/observability-synthetics-test-data": "link:x-pack/packages/observability/synthetics_test_data",
"@kbn/observability-utils": "link:x-pack/packages/observability/observability_utils",
"@kbn/oidc-provider-plugin": "link:x-pack/test/security_api_integration/plugins/oidc_provider",
"@kbn/open-telemetry-instrumented-plugin": "link:test/common/plugins/otel_metrics",

View file

@ -88,13 +88,15 @@ const ObservabilityUptimeAlertOptional = rt.partial({
value: schemaStringArray,
})
),
'location.id': schemaString,
'location.name': schemaString,
'location.id': schemaStringArray,
'location.name': schemaStringArray,
'monitor.id': schemaString,
'monitor.name': schemaString,
'monitor.state.id': schemaString,
'monitor.tags': schemaStringArray,
'monitor.type': schemaString,
'observer.geo.name': schemaString,
'observer.geo.name': schemaStringArray,
'observer.name': schemaStringArray,
'tls.server.hash.sha256': schemaString,
'tls.server.x509.issuer.common_name': schemaString,
'tls.server.x509.not_after': schemaDate,

View file

@ -1306,6 +1306,8 @@
"@kbn/observability-plugin/*": ["x-pack/plugins/observability_solution/observability/*"],
"@kbn/observability-shared-plugin": ["x-pack/plugins/observability_solution/observability_shared"],
"@kbn/observability-shared-plugin/*": ["x-pack/plugins/observability_solution/observability_shared/*"],
"@kbn/observability-synthetics-test-data": ["x-pack/packages/observability/synthetics_test_data"],
"@kbn/observability-synthetics-test-data/*": ["x-pack/packages/observability/synthetics_test_data/*"],
"@kbn/observability-utils": ["x-pack/packages/observability/observability_utils"],
"@kbn/observability-utils/*": ["x-pack/packages/observability/observability_utils/*"],
"@kbn/oidc-provider-plugin": ["x-pack/test/security_api_integration/plugins/oidc_provider"],

View file

@ -0,0 +1,3 @@
# @kbn/observability-synthetics-test-data
Provides utilities to generate synthetics test data

View file

@ -0,0 +1,8 @@
/*
* 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 { makeUpSummary, makeDownSummary } from './src/make_summaries';

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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/packages/observability/synthetics_test_data'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/observability-synthetics-test-data",
"owner": "@elastic/obs-ux-management-team",
}

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/observability-synthetics-test-data",
"descriptio": "Utils to generate observability synthetics test data",
"author": "UX Management",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -6,124 +6,39 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { getGeoData } from './browser_docs';
import moment from 'moment';
import { getGeoData } from './utils';
export interface DocOverrides {
timestamp?: string;
monitorId?: string;
name?: string;
testRunId?: string;
locationName?: string;
location?: {
id: string;
label: string;
};
configId?: string;
}
export const getUpHit = ({
export const makeUpSummary = ({
name,
timestamp,
monitorId,
configId,
testRunId,
locationName,
location,
}: DocOverrides = {}) => ({
...getGeoData(locationName),
...getGeoData(location),
...commons,
summary: {
up: 1,
down: 0,
final_attempt: true,
},
tcp: {
rtt: {
connect: {
us: 22245,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
type: 'heartbeat',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3101,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 155239,
},
ip: '142.250.181.196',
origin: 'ui',
name: name ?? 'Test Monitor',
timespan: {
lt: '2022-12-18T09:55:04.211Z',
gte: '2022-12-18T09:52:04.211Z',
},
fleet_managed: true,
id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
check_group: 'a039fd21-7eb9-11ed-8949-0242ac120006',
type: 'http',
status: 'up',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
monitor: getMonitorData({ id: monitorId, name, status: 'up', timestamp }),
'@timestamp': timestamp ?? '2022-12-18T09:52:04.056Z',
ecs: {
version: '8.0.0',
},
config_id: configId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
established: true,
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_curve: 'P-256',
public_key_algorithm: 'ECDSA',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 35023,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
@ -135,67 +50,81 @@ export const getUpHit = ({
flap_history: [],
status: 'up',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:52:11Z',
dataset: 'http',
},
...(testRunId && { test_run_id: testRunId }),
http: {
rtt: {
response_header: {
us: 144758,
},
total: {
us: 149191,
},
write_request: {
us: 48,
},
content: {
us: 401,
},
validate: {
us: 145160,
},
},
response: {
headers: {
Server: 'gws',
P3p: 'CP="This is not a P3P policy! See g.co/p3phelp for more info."',
Date: 'Thu, 29 Dec 2022 08:17:09 GMT',
'X-Frame-Options': 'SAMEORIGIN',
'Accept-Ranges': 'none',
'Cache-Control': 'private, max-age=0',
'X-Xss-Protection': '0',
'Cross-Origin-Opener-Policy-Report-Only': 'same-origin-allow-popups; report-to="gws"',
Vary: 'Accept-Encoding',
Expires: '-1',
'Content-Type': 'text/html; charset=ISO-8859-1',
},
status_code: 200,
mime_type: 'text/html; charset=utf-8',
body: {
bytes: 13963,
hash: 'a4c2cf7dead9fb9329fc3727fc152b6a12072410926430491d02a0c6dc3a70ff',
},
},
},
});
export const firstDownHit = ({
export const makeDownSummary = ({
name,
timestamp,
monitorId,
locationName,
location,
configId,
}: DocOverrides = {}) => ({
...getGeoData(locationName),
...getGeoData(location),
...commons,
summary: {
up: 0,
down: 1,
final_attempt: true,
},
monitor: getMonitorData({ id: monitorId, name, status: 'down', timestamp }),
error: {
message: 'received status code 200 expecting [500]',
type: 'validate',
},
'@timestamp': timestamp ?? '2022-12-18T09:49:49.976Z',
config_id: configId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:49:56.007551998Z',
id: 'Test private location-18524a3d9a7-0',
up: 0,
down: 1,
flap_history: [],
status: 'down',
},
});
const getMonitorData = ({
id,
name,
status,
timestamp,
}: {
id?: string;
name?: string;
status: 'up' | 'down';
timestamp?: string;
}) => ({
duration: {
us: 152459,
},
origin: 'ui',
ip: '142.250.181.196',
name: name ?? 'Test Monitor',
fleet_managed: true,
check_group: uuidv4(),
timespan: {
lt: timestamp ?? '2022-12-18T09:52:50.128Z',
gte: timestamp ? moment(timestamp).subtract(3, 'minutes') : '2022-12-18T09:49:50.128Z',
},
id: id ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
type: 'http',
status: status ?? 'down',
});
const commons = {
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
ecs: {
version: '8.0.0',
},
tcp: {
rtt: {
connect: {
@ -203,6 +132,11 @@ export const firstDownHit = ({
},
},
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:49:57Z',
dataset: 'http',
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
@ -210,54 +144,22 @@ export const firstDownHit = ({
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3234,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 152459,
},
origin: 'ui',
ip: '142.250.181.196',
name: name ?? 'Test Monitor',
fleet_managed: true,
check_group: uuidv4(),
timespan: {
lt: '2022-12-18T09:52:50.128Z',
gte: '2022-12-18T09:49:50.128Z',
},
id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
type: 'http',
status: 'down',
},
error: {
message: 'received status code 200 expecting [500]',
type: 'validate',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
'@timestamp': timestamp ?? '2022-12-18T09:49:49.976Z',
ecs: {
version: '8.0.0',
},
config_id: configId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
resolve: {
rtt: {
us: 3101,
},
ip: '142.250.181.196',
},
tls: {
established: true,
cipher: 'TLS-AES-128-GCM-SHA256',
@ -293,22 +195,6 @@ export const firstDownHit = ({
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:49:56.007551998Z',
id: 'Test private location-18524a3d9a7-0',
up: 0,
down: 1,
flap_history: [],
status: 'down',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:49:57Z',
dataset: 'http',
},
http: {
rtt: {
response_header: {
@ -349,4 +235,4 @@ export const firstDownHit = ({
},
},
},
});
};

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.
*/
export const getGeoData = ({ id, label }: { label?: string; id?: string } = {}) => ({
observer: {
geo: {
name: label ?? 'Dev Service',
location: '41.8780, 93.0977',
},
name: id ?? 'dev',
},
});

View file

@ -0,0 +1,20 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
]
}

View file

@ -9852,10 +9852,12 @@ Object {
"type": "keyword",
},
"location.id": Object {
"array": true,
"required": false,
"type": "keyword",
},
"location.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -9867,6 +9869,10 @@ Object {
"required": false,
"type": "keyword",
},
"monitor.state.id": Object {
"required": false,
"type": "keyword",
},
"monitor.tags": Object {
"array": true,
"required": false,
@ -9877,6 +9883,12 @@ Object {
"type": "keyword",
},
"observer.geo.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
"observer.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -9972,10 +9984,12 @@ Object {
"type": "keyword",
},
"location.id": Object {
"array": true,
"required": false,
"type": "keyword",
},
"location.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -9987,6 +10001,10 @@ Object {
"required": false,
"type": "keyword",
},
"monitor.state.id": Object {
"required": false,
"type": "keyword",
},
"monitor.tags": Object {
"array": true,
"required": false,
@ -9997,6 +10015,12 @@ Object {
"type": "keyword",
},
"observer.geo.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
"observer.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10092,10 +10116,12 @@ Object {
"type": "keyword",
},
"location.id": Object {
"array": true,
"required": false,
"type": "keyword",
},
"location.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10107,6 +10133,10 @@ Object {
"required": false,
"type": "keyword",
},
"monitor.state.id": Object {
"required": false,
"type": "keyword",
},
"monitor.tags": Object {
"array": true,
"required": false,
@ -10117,6 +10147,12 @@ Object {
"type": "keyword",
},
"observer.geo.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
"observer.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10212,10 +10248,12 @@ Object {
"type": "keyword",
},
"location.id": Object {
"array": true,
"required": false,
"type": "keyword",
},
"location.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10227,6 +10265,10 @@ Object {
"required": false,
"type": "keyword",
},
"monitor.state.id": Object {
"required": false,
"type": "keyword",
},
"monitor.tags": Object {
"array": true,
"required": false,
@ -10237,6 +10279,12 @@ Object {
"type": "keyword",
},
"observer.geo.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
"observer.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10338,10 +10386,12 @@ Object {
"type": "keyword",
},
"location.id": Object {
"array": true,
"required": false,
"type": "keyword",
},
"location.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
@ -10353,6 +10403,10 @@ Object {
"required": false,
"type": "keyword",
},
"monitor.state.id": Object {
"required": false,
"type": "keyword",
},
"monitor.tags": Object {
"array": true,
"required": false,
@ -10363,6 +10417,12 @@ Object {
"type": "keyword",
},
"observer.geo.name": Object {
"array": true,
"required": false,
"type": "keyword",
},
"observer.name": Object {
"array": true,
"required": false,
"type": "keyword",
},

View file

@ -54,44 +54,32 @@ export const FINAL_SUMMARY_FILTER = {
},
},
{
bool: {
should: [
{
bool: {
should: [
{
match: {
'summary.final_attempt': true,
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
exists: {
field: 'summary.final_attempt',
},
},
],
minimum_should_match: 1,
},
},
},
},
],
minimum_should_match: 1,
term: {
'summary.final_attempt': true,
},
},
],
},
};
export const getRangeFilter = ({ from, to }: { from: string; to: string }) => ({
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
});
export const getTimespanFilter = ({ from, to }: { from: string; to: string }) => ({
range: {
'monitor.timespan': {
gte: from,
lte: to,
},
},
});
export const SUMMARY_FILTER = { exists: { field: 'summary' } };
export const getLocationFilter = ({

View file

@ -11,6 +11,7 @@ export const MONITOR_NAME = 'monitor.name';
export const MONITOR_TYPE = 'monitor.type';
export const URL_FULL = 'url.full';
export const URL_PORT = 'url.port';
export const OBSERVER_NAME = 'observer.name';
export const OBSERVER_GEO_NAME = 'observer.geo.name';
export const ERROR_MESSAGE = 'error.message';
export const STATE_ID = 'monitor.state.id';

View file

@ -53,7 +53,7 @@ describe('Alert Actions factory', () => {
dedupKey: expect.any(String),
eventAction: 'resolve',
summary:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
'The alert for monitor "{{context.monitorName}}" from {{context.locationNames}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationNames}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
},
},
{
@ -193,7 +193,7 @@ describe('Alert Actions factory', () => {
dedupKey: expect.any(String),
eventAction: 'resolve',
summary:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
'The alert for monitor "{{context.monitorName}}" from {{context.locationNames}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationNames}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
},
},
{
@ -230,8 +230,7 @@ describe('Alert Actions factory', () => {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
eventAction: 'trigger',
severity: 'error',
summary:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}.\n\nDetails:\n\nMonitor name: {{context.monitorName}}\n{{context.monitorUrlLabel}}: {{{context.monitorUrl}}}\nMonitor type: {{context.monitorType}}\nFrom: {{context.locationName}}\nLatest error received: {{{context.lastErrorMessage}}}\n{{{context.linkMessage}}}',
summary: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage,
},
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
},
@ -263,11 +262,9 @@ describe('Alert Actions factory', () => {
path: '',
text: '',
},
message:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
message: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage,
messageHTML: null,
subject:
'"{{context.monitorName}}" ({{context.locationName}}) {{context.recoveryStatus}} - Elastic Synthetics',
subject: SyntheticsMonitorStatusTranslations.defaultRecoverySubjectMessage,
to: ['test@email.com'],
},
},
@ -286,11 +283,9 @@ describe('Alert Actions factory', () => {
path: '',
text: '',
},
message:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
message: SyntheticsMonitorStatusTranslations.defaultActionMessage,
messageHTML: null,
subject:
'"{{context.monitorName}}" ({{context.locationName}}) is down - Elastic Synthetics',
subject: SyntheticsMonitorStatusTranslations.defaultSubjectMessage,
to: ['test@email.com'],
},
},

View file

@ -0,0 +1,38 @@
/*
* 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 { getConditionType } from './status_rule';
describe('Status Rule', () => {
it('should return the correct condition type for empty', () => {
const { useLatestChecks } = getConditionType({} as any);
expect(useLatestChecks).toBe(true);
});
it('should return the correct condition type check based', () => {
const { useLatestChecks, useTimeWindow } = getConditionType({
window: {
numberOfChecks: 5,
},
});
expect(useLatestChecks).toBe(true);
expect(useTimeWindow).toBe(false);
});
it('should return the correct condition type time based', () => {
const { useTimeWindow, useLatestChecks } = getConditionType({
window: {
time: {
unit: 'm',
size: 5,
},
},
});
expect(useTimeWindow).toBe(true);
expect(useLatestChecks).toBe(false);
});
});

View file

@ -6,7 +6,102 @@
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { isEmpty } from 'lodash';
export const StatusRulePramsSchema = schema.object({});
export const TimeWindowSchema = schema.object({
unit: schema.oneOf(
[schema.literal('s'), schema.literal('m'), schema.literal('h'), schema.literal('d')],
{
defaultValue: 'm',
}
),
size: schema.number({
defaultValue: 5,
}),
});
export const NumberOfChecksSchema = schema.object({
numberOfChecks: schema.number({
defaultValue: 5,
min: 1,
max: 100,
}),
});
export const StatusRuleConditionSchema = schema.object({
groupBy: schema.maybe(
schema.string({
defaultValue: 'locationId',
})
),
downThreshold: schema.maybe(
schema.number({
defaultValue: 3,
})
),
locationsThreshold: schema.maybe(
schema.number({
defaultValue: 1,
})
),
window: schema.oneOf([
schema.object({
time: TimeWindowSchema,
}),
NumberOfChecksSchema,
]),
includeRetests: schema.maybe(schema.boolean()),
});
export const StatusRulePramsSchema = schema.object({
condition: schema.maybe(StatusRuleConditionSchema),
monitorIds: schema.maybe(schema.arrayOf(schema.string())),
locations: schema.maybe(schema.arrayOf(schema.string())),
tags: schema.maybe(schema.arrayOf(schema.string())),
monitorTypes: schema.maybe(schema.arrayOf(schema.string())),
projects: schema.maybe(schema.arrayOf(schema.string())),
kqlQuery: schema.maybe(schema.string()),
});
export type TimeWindow = TypeOf<typeof TimeWindowSchema>;
export type StatusRuleParams = TypeOf<typeof StatusRulePramsSchema>;
export type StatusRuleCondition = TypeOf<typeof StatusRuleConditionSchema>;
export const getConditionType = (condition?: StatusRuleCondition) => {
let numberOfChecks = 1;
let timeWindow: TimeWindow = { unit: 'm', size: 1 };
if (isEmpty(condition) || !condition?.window) {
return {
isLocationBased: false,
useTimeWindow: false,
timeWindow,
useLatestChecks: true,
numberOfChecks,
downThreshold: 1,
locationsThreshold: 1,
isDefaultRule: true,
};
}
const useTimeWindow = condition.window && 'time' in condition.window;
const useLatestChecks = condition.window && 'numberOfChecks' in condition.window;
if (useLatestChecks) {
numberOfChecks =
condition && 'numberOfChecks' in condition.window ? condition.window.numberOfChecks : 1;
}
if (useTimeWindow) {
timeWindow = condition.window.time;
numberOfChecks = condition?.downThreshold ?? 1;
}
return {
useTimeWindow,
timeWindow,
useLatestChecks,
numberOfChecks,
locationsThreshold: condition?.locationsThreshold ?? 1,
downThreshold: condition?.downThreshold ?? 1,
isDefaultRule: isEmpty(condition),
};
};

View file

@ -12,7 +12,7 @@ export const SyntheticsMonitorStatusTranslations = {
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultActionMessage',
{
// the extra spaces before `\n` are needed to properly convert this from markdown to an HTML email
defaultMessage: `"{monitorName}" is {status} from {locationName}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- Monitor type: {monitorType} \n- Checked at: {checkedAt} \n- From: {locationName} \n- Error received: {lastErrorMessage} \n{linkMessage}`,
defaultMessage: `Monitor "{monitorName}" is {status} from {locationNames}.{pendingLastRunAt} - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- Monitor type: {monitorType} \n- Checked at: {checkedAt} \n- From: {locationNames} \n- Reason: {reason} \n- Error received: {lastErrorMessage} \n{linkMessage}`,
values: {
monitorName: '{{context.monitorName}}',
monitorType: '{{context.monitorType}}',
@ -20,29 +20,32 @@ export const SyntheticsMonitorStatusTranslations = {
monitorUrlLabel: '{{context.monitorUrlLabel}}',
status: '{{{context.status}}}',
lastErrorMessage: '{{{context.lastErrorMessage}}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
checkedAt: '{{context.checkedAt}}',
linkMessage: '{{{context.linkMessage}}}',
pendingLastRunAt: '{{{context.pendingLastRunAt}}}',
reason: '{{{context.reason}}}',
},
}
),
defaultSubjectMessage: i18n.translate(
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultSubjectMessage',
{
defaultMessage: '"{monitorName}" ({locationName}) is down - Elastic Synthetics',
defaultMessage: 'Monitor "{monitorName}" ({locationNames}) is down - Elastic Synthetics',
values: {
monitorName: '{{context.monitorName}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
},
}
),
defaultRecoverySubjectMessage: i18n.translate(
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoverySubjectMessage',
{
defaultMessage: '"{monitorName}" ({locationName}) {recoveryStatus} - Elastic Synthetics',
defaultMessage:
'Monitor "{monitorName}" ({locationNames}) {recoveryStatus} - Elastic Synthetics',
values: {
recoveryStatus: '{{context.recoveryStatus}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
monitorName: '{{context.monitorName}}',
},
}
@ -52,13 +55,13 @@ export const SyntheticsMonitorStatusTranslations = {
{
// the extra spaces before `\n` are needed to properly convert this from markdown to an HTML email
defaultMessage:
'The alert for "{monitorName}" from {locationName} is no longer active: {recoveryReason}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationName} \n- Last error received: {lastErrorMessage} \n{linkMessage}',
'The alert for monitor "{monitorName}" from {locationNames} is no longer active: {recoveryReason}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationNames} \n- Last error received: {lastErrorMessage} \n{linkMessage}',
values: {
monitorName: '{{context.monitorName}}',
monitorUrlLabel: '{{context.monitorUrlLabel}}',
monitorUrl: '{{{context.monitorUrl}}}',
monitorType: '{{context.monitorType}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
recoveryReason: '{{context.recoveryReason}}',
lastErrorMessage: '{{{context.lastErrorMessage}}}',
linkMessage: '{{{context.linkMessage}}}',
@ -75,7 +78,7 @@ export const SyntheticsMonitorStatusTranslations = {
export const TlsTranslations = {
defaultActionMessage: i18n.translate('xpack.synthetics.rules.tls.defaultActionMessage', {
defaultMessage: `TLS certificate {commonName} {status} - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- Common name: {commonName}\n- Issuer: {issuer}\n- Monitor: {monitorName} \n- Monitor URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationName}`,
defaultMessage: `TLS certificate {commonName} {status} - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- Common name: {commonName}\n- Issuer: {issuer}\n- Monitor: {monitorName} \n- Monitor URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationNames}`,
values: {
commonName: '{{context.commonName}}',
issuer: '{{context.issuer}}',
@ -84,11 +87,11 @@ export const TlsTranslations = {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
monitorType: '{{context.monitorType}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
},
}),
defaultRecoveryMessage: i18n.translate('xpack.synthetics.rules.tls.defaultRecoveryMessage', {
defaultMessage: `TLS alert for monitor "{monitorName}" has recovered - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- New status : {newStatus}\n- Previous status: {previousStatus}\n- Monitor: {monitorName} \n- URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationName}`,
defaultMessage: `TLS alert for monitor "{monitorName}" has recovered - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- New status : {newStatus}\n- Previous status: {previousStatus}\n- Monitor: {monitorName} \n- URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationNames}`,
values: {
summary: '{{context.summary}}',
previousStatus: '{{context.previousStatus}}',
@ -96,7 +99,7 @@ export const TlsTranslations = {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
monitorType: '{{context.monitorType}}',
locationName: '{{context.locationName}}',
locationNames: '{{context.locationNames}}',
},
}),
name: i18n.translate('xpack.synthetics.rules.tls.clientName', {

View file

@ -17,8 +17,14 @@ export const syntheticsRuleFieldMap: FieldMap = {
type: 'keyword',
required: false,
},
'observer.name': {
type: 'keyword',
array: true,
required: false,
},
'observer.geo.name': {
type: 'keyword',
array: true,
required: false,
},
// monitor status alert fields
@ -43,6 +49,10 @@ export const syntheticsRuleFieldMap: FieldMap = {
array: true,
required: false,
},
'monitor.state.id': {
type: 'keyword',
required: false,
},
configId: {
type: 'keyword',
required: false,
@ -53,10 +63,12 @@ export const syntheticsRuleFieldMap: FieldMap = {
},
'location.id': {
type: 'keyword',
array: true,
required: false,
},
'location.name': {
type: 'keyword',
array: true,
required: false,
},
// tls alert fields

View file

@ -25,14 +25,7 @@ export const SyntheticsCommonStateCodec = t.intersection([
export type SyntheticsCommonState = t.TypeOf<typeof SyntheticsCommonStateCodec>;
export const SyntheticsMonitorStatusAlertStateCodec = t.type({
configId: t.string,
locationId: t.string,
locationName: t.string,
errorStartedAt: t.string,
lastErrorMessage: t.string,
stateId: t.string,
});
export const SyntheticsMonitorStatusAlertStateCodec = t.type({});
export type SyntheticsMonitorStatusAlertState = t.TypeOf<
typeof SyntheticsMonitorStatusAlertStateCodec
@ -45,6 +38,10 @@ export const AlertStatusMetaDataCodec = t.interface({
locationId: t.string,
timestamp: t.string,
ping: OverviewPingCodec,
checks: t.type({
downWithinXChecks: t.number,
down: t.number,
}),
});
export const StaleAlertStatusMetaDataCodec = t.intersection([
@ -69,9 +66,6 @@ export const AlertPendingStatusMetaDataCodec = t.intersection([
]);
export const AlertStatusCodec = t.interface({
up: t.number,
down: t.number,
pending: t.number,
upConfigs: t.record(t.string, AlertStatusMetaDataCodec),
downConfigs: t.record(t.string, AlertStatusMetaDataCodec),
pendingConfigs: t.record(t.string, AlertPendingStatusMetaDataCodec),
@ -79,7 +73,7 @@ export const AlertStatusCodec = t.interface({
staleDownConfigs: t.record(t.string, StaleAlertStatusMetaDataCodec),
});
export type AlertPendingStatusMetaData = t.TypeOf<typeof AlertPendingStatusMetaDataCodec>;
export type StaleDownConfig = t.TypeOf<typeof StaleAlertStatusMetaDataCodec>;
export type AlertStatusMetaData = t.TypeOf<typeof AlertStatusMetaDataCodec>;
export type AlertOverviewStatus = t.TypeOf<typeof AlertStatusCodec>;
export type AlertStatusConfigs = Record<string, AlertStatusMetaData>;

View file

@ -96,6 +96,10 @@ export const MonitorType = t.intersection([
status: t.string,
type: t.string,
check_group: t.string,
timespan: t.type({
gte: t.string,
lt: t.string,
}),
}),
t.partial({
duration: t.type({
@ -103,10 +107,7 @@ export const MonitorType = t.intersection([
}),
ip: t.string,
name: t.string,
timespan: t.type({
gte: t.string,
lt: t.string,
}),
fleet_managed: t.boolean,
project: t.type({
id: t.string,

View file

@ -0,0 +1,78 @@
/*
* 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 { journey, step, before, after, expect } from '@elastic/synthetics';
import { RetryService } from '@kbn/ftr-common-functional-services';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics_app';
import { SyntheticsServices } from '../services/synthetics_services';
journey(`CustomStatusAlert`, async ({ page, params }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params });
const services = new SyntheticsServices(params);
const getService = params.getService;
const retry: RetryService = getService('retry');
const firstCheckTime = new Date(Date.now()).toISOString();
let configId: string;
before(async () => {
await services.cleaUp();
});
after(async () => {
await services.cleaUp();
});
step('Go to monitors page', async () => {
await syntheticsApp.navigateToOverview(true, 15);
});
step('add test monitor', async () => {
configId = await services.addTestMonitor(
'Test Monitor',
{
type: 'http',
urls: 'https://www.google.com',
locations: ['us_central'],
},
configId
);
await services.addTestSummaryDocument({ timestamp: firstCheckTime, configId });
});
step('should create status rule', async () => {
await page.getByTestId('syntheticsRefreshButtonButton').click();
await page.getByTestId('syntheticsAlertsRulesButton').click();
await page.getByTestId('manageStatusRuleName').click();
await page.getByTestId('createNewStatusRule').click();
await page.getByTestId('ruleNameInput').fill('Synthetics status rule');
await page.getByTestId('saveRuleButton').click();
await page.getByTestId('confirmModalConfirmButton').click();
await page.waitForSelector(`text='Created rule "Synthetics status rule"'`);
});
step('verify rule creation', async () => {
await retry.try(async () => {
const rules = await services.getRules();
expect(rules.length).toBe(3);
expect(rules[2].params).toStrictEqual({
condition: {
downThreshold: 3,
locationsThreshold: 1,
groupBy: 'locationId',
window: {
numberOfChecks: 5,
},
},
});
});
});
});

View file

@ -29,42 +29,50 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
before(async () => {
await services.cleaUp();
await services.enableMonitorManagedViaApi();
configId = await services.addTestMonitor('Test Monitor', {
type: 'http',
urls: 'https://www.google.com',
custom_heartbeat_id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
locations: [
{ id: 'us_central', label: 'North America - US Central', isServiceManaged: true },
],
});
await services.addTestSummaryDocument({ timestamp: firstCheckTime, configId });
});
after(async () => {
await services.cleaUp();
});
step('setup monitor', async () => {
const connectorId = await services.setupTestConnector();
await services.setupSettings(connectorId.id);
configId = await services.addTestMonitor('Test Monitor', {
type: 'http',
urls: 'https://www.google.com',
locations: [
{ id: 'us_central', label: 'North America - US Central', isServiceManaged: true },
],
});
await services.addTestSummaryDocument({
timestamp: firstCheckTime,
configId,
});
});
step('Go to monitors page', async () => {
await syntheticsApp.navigateToOverview(true, 15);
});
step('should create default status alert', async () => {
await page.click(byTestId('xpack.synthetics.alertsPopover.toggleButton'));
await page.isDisabled(byTestId('xpack.synthetics.toggleAlertFlyout'));
await page.click(byTestId('xpack.synthetics.toggleAlertFlyout'));
await page.getByTestId('syntheticsAlertsRulesButton').click();
await page.getByTestId('manageStatusRuleName').click();
await page.isDisabled(byTestId('editDefaultStatusRule'));
await page.getByTestId('editDefaultStatusRule').click();
await page.waitForSelector('text=Monitor status rule');
expect(await page.locator(`[data-test-subj="intervalFormRow"]`).count()).toEqual(0);
await page.getByTestId('intervalInputUnit').selectOption('second');
await page.getByTestId('intervalInput').fill('20');
await page.click(byTestId('saveEditedRuleButton'));
await page.waitForSelector("text=Updated 'Synthetics status internal rule'");
});
step('Monitor is as up in overview page', async () => {
await retry.tryForTime(90 * 1000, async () => {
const totalDown = await page.textContent(
byTestId('xpack.uptime.synthetics.overview.status.up')
);
expect(totalDown).toBe('1Up');
const totalUp = await page.textContent(byTestId('syntheticsOverviewUp'));
expect(totalUp).toBe('1Up');
});
await page.hover('text=Test Monitor');
@ -74,6 +82,8 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
step('Disable default alert for monitor', async () => {
await page.click('text=Disable status alert');
await page.waitForSelector(`text=Alerts are now disabled for the monitor "Test Monitor".`);
await page.getByTestId('Test Monitor-us_central-metric-item').hover();
await page.click('[aria-label="Open actions menu"]');
await page.click('text=Enable status alert');
});
@ -91,9 +101,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
await page.waitForTimeout(5 * 1000);
const totalDown = await page.textContent(
byTestId('xpack.uptime.synthetics.overview.status.down')
);
const totalDown = await page.textContent(byTestId('syntheticsOverviewDown'));
expect(totalDown).toBe('1Down');
});
@ -103,14 +111,17 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
const reasonMessage = getReasonMessage({
name: 'Test Monitor',
location: 'North America - US Central',
timestamp: downCheckTime,
status: 'down',
checks: {
downWithinXChecks: 1,
down: 1,
},
});
await retry.tryForTime(3 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
const alerts = await page.waitForSelector(`text=1 Alert`, { timeout: 20 * 1000 });
const alerts = await page.waitForSelector(`text=1 Alert`, { timeout: 5 * 1000 });
expect(await alerts.isVisible()).toBe(true);
const text = await page.textContent(`${byTestId('dataGridRowCell')} .euiLink`);
@ -164,8 +175,11 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
const reasonMessage = getReasonMessage({
name,
location: 'North America - US Central',
timestamp: downCheckTime,
status: 'down',
checks: {
downWithinXChecks: 1,
down: 1,
},
});
await retry.tryForTime(3 * 60 * 1000, async () => {
@ -194,6 +208,5 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
await page.waitForTimeout(10 * 1000);
await page.click('[aria-label="View in app"]');
await page.click(byTestId('breadcrumb /app/synthetics/monitors'));
});
});

View file

@ -18,7 +18,8 @@ export * from './private_locations.journey';
export * from './alerting_default.journey';
export * from './global_parameters.journey';
export * from './detail_flyout';
// export * from './alert_rules/default_status_alert.journey';
export * from './alert_rules/default_status_alert.journey';
export * from './alert_rules/custom_status_alert.journey';
export * from './test_now_mode.journey';
export * from './monitor_details_page/monitor_summary.journey';
export * from './test_run_details.journey';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { DocOverrides } from './sample_docs';
import { DocOverrides } from '@kbn/observability-synthetics-test-data/src/make_summaries';
export const getGeoData = (locationName?: string, locationId?: string) => ({
observer: {
@ -22,10 +22,10 @@ export const journeySummary = ({
timestamp,
monitorId,
testRunId,
locationName,
location,
}: DocOverrides = {}) => {
return {
...getGeoData(locationName),
...getGeoData(location?.label),
summary: {
up: 1,
down: 0,
@ -105,9 +105,9 @@ export const journeyStart = ({
timestamp,
monitorId,
testRunId,
locationName,
location,
}: DocOverrides = {}) => ({
...getGeoData(locationName),
...getGeoData(location?.label),
test_run_id: testRunId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
agent: {
name: 'job-88fe737c53c39aea-lp69x',
@ -167,14 +167,8 @@ export const journeyStart = ({
},
});
export const step1 = ({
name,
timestamp,
monitorId,
testRunId,
locationName,
}: DocOverrides = {}) => ({
...getGeoData(locationName),
export const step1 = ({ name, timestamp, monitorId, testRunId, location }: DocOverrides = {}) => ({
...getGeoData(location?.label),
test_run_id: testRunId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
agent: {
name: 'job-76905d93798e6fff-z6nsb',
@ -249,14 +243,8 @@ export const step1 = ({
},
});
export const step2 = ({
name,
timestamp,
monitorId,
testRunId,
locationName,
}: DocOverrides = {}) => ({
...getGeoData(locationName),
export const step2 = ({ name, timestamp, monitorId, testRunId, location }: DocOverrides = {}) => ({
...getGeoData(location?.label),
test_run_id: testRunId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
agent: {
name: 'job-76905d93798e6fff-z6nsb',

View file

@ -9,10 +9,10 @@ import axios from 'axios';
import type { Client } from '@elastic/elasticsearch';
import { KbnClient } from '@kbn/test';
import pMap from 'p-map';
import { makeDownSummary, makeUpSummary } from '@kbn/observability-synthetics-test-data';
import { SyntheticsMonitor } from '@kbn/synthetics-plugin/common/runtime_types';
import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants';
import { journeyStart, journeySummary, step1, step2 } from './data/browser_docs';
import { firstDownHit, getUpHit } from './data/sample_docs';
export class SyntheticsServices {
kibanaUrl: string;
@ -113,22 +113,6 @@ export class SyntheticsServices {
);
}
async enableDefaultAlertingViaApi() {
try {
await axios.post(
this.kibanaUrl + SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING,
{ isDisabled: false },
{
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'synthetics-e2e' },
}
);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
}
async addTestSummaryDocument({
docType = 'summaryUp',
timestamp = new Date(Date.now()).toISOString(),
@ -157,14 +141,22 @@ export class SyntheticsServices {
let index = 'synthetics-http-default';
const commonData = { timestamp, monitorId, name, testRunId, locationName, configId };
const commonData = {
timestamp,
name,
testRunId,
location: {
id: 'us_central',
label: locationName ?? 'North America - US Central',
},
configId,
monitorId: monitorId ?? configId,
};
switch (docType) {
case 'stepEnd':
index = 'synthetics-browser-default';
const stepDoc = stepIndex === 1 ? step1(commonData) : step2(commonData);
document = { ...stepDoc, ...document };
break;
case 'journeyEnd':
@ -177,19 +169,19 @@ export class SyntheticsServices {
break;
case 'summaryDown':
document = {
...firstDownHit(commonData),
...makeDownSummary(commonData),
...document,
};
break;
case 'summaryUp':
document = {
...getUpHit(commonData),
...makeUpSummary(commonData),
...document,
};
break;
default:
document = {
...getUpHit(commonData),
...makeUpSummary(commonData),
...document,
};
}
@ -228,4 +220,43 @@ export class SyntheticsServices {
console.log(e);
}
}
async getRules() {
const response = await axios.get(this.kibanaUrl + '/internal/alerting/rules/_find', {
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true' },
});
return response.data.data;
}
async setupTestConnector() {
const indexConnector = {
name: 'test index',
config: { index: 'test-index' },
secrets: {},
connector_type_id: '.index',
};
const connector = await this.requester.request({
path: `/api/actions/connector`,
method: 'POST',
body: indexConnector,
});
return connector.data as any;
}
async setupSettings(connectorId?: string) {
const settings = {
certExpirationThreshold: 30,
certAgeThreshold: 730,
defaultConnectors: [connectorId],
defaultEmail: { to: [], cc: [], bcc: [] },
defaultStatusRuleEnabled: true,
};
const connector = await this.requester.request({
path: `/api/synthetics/settings`,
method: 'PUT',
body: settings,
});
return connector.data;
}
}

View file

@ -14,8 +14,9 @@
"@kbn/ftr-common-functional-services",
"@kbn/apm-plugin",
"@kbn/es-archiver",
"@kbn/repo-info",
"@kbn/synthetics-plugin",
"@kbn/repo-info",
"@kbn/observability-synthetics-test-data",
"@kbn/ftr-common-functional-ui-services"
]
}

View file

@ -0,0 +1,59 @@
/*
* 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 React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFieldNumber, EuiPopoverTitle } from '@elastic/eui';
import { StatusRuleCondition } from '../../../../../../common/rules/status_rule';
import { PopoverExpression } from './popover_expression';
import { StatusRuleParamsProps } from '../status_rule_ui';
interface Props {
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}
export const LocationsValueExpression = ({ ruleParams, setRuleParams }: Props) => {
const { condition } = ruleParams;
const onLocationCountChange = useCallback(
(value: number) => {
setRuleParams('condition', {
...ruleParams.condition,
locationsThreshold: value,
groupBy: value === 1 ? ruleParams.condition?.groupBy : 'none',
} as StatusRuleCondition);
},
[ruleParams.condition, setRuleParams]
);
const locationsThreshold =
condition && 'locationsThreshold' in condition ? condition.locationsThreshold ?? 1 : 1;
return (
<PopoverExpression
value={i18n.translate('xpack.synthetics.windowValueExpression.percentLabel', {
defaultMessage:
'{numberOfLocations} {numberOfLocations, plural, one {location} other {locations}}',
values: { numberOfLocations: locationsThreshold },
})}
>
<EuiPopoverTitle>
{i18n.translate('xpack.synthetics.windowValueExpression.numberOfLocPopoverTitleLabel', {
defaultMessage: 'Number of locations',
})}
</EuiPopoverTitle>
<EuiFieldNumber
data-test-subj="syntheticsWindowValueExpressionFieldNumber"
min={1}
max={100}
compressed
value={locationsThreshold}
onChange={(evt) => onLocationCountChange(Number(evt.target.value))}
/>
</PopoverExpression>
);
};

View file

@ -0,0 +1,107 @@
/*
* 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 { ForLastExpression, TIME_UNITS } from '@kbn/triggers-actions-ui-plugin/public';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFieldNumber, EuiPopoverTitle } from '@elastic/eui';
import { PopoverExpression } from './popover_expression';
import { getConditionType, TimeWindow } from '../../../../../../common/rules/status_rule';
import { StatusRuleParamsProps } from '../status_rule_ui';
interface Props {
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}
export const WindowValueExpression = ({ ruleParams, setRuleParams }: Props) => {
const { condition } = ruleParams;
const timeWindow =
condition && 'time' in condition.window
? condition.window.time ?? {
size: 5,
unit: 'm',
}
: null;
const timeWindowSize = timeWindow?.size ?? 5;
const timeWindowUnit = timeWindow?.unit ?? 'm';
const numberOfChecks =
condition && 'numberOfChecks' in condition.window ? condition.window.numberOfChecks : null;
const { useTimeWindow } = getConditionType(ruleParams.condition);
const onTimeWindowChange = useCallback(
(value: TimeWindow) => {
setRuleParams('condition', {
...ruleParams.condition,
window: {
...ruleParams.condition?.window,
time: value,
},
});
},
[ruleParams.condition, setRuleParams]
);
const onNumberOfChecksChange = useCallback(
(value: number) => {
setRuleParams('condition', {
...ruleParams.condition,
window: {
...ruleParams.condition?.window,
numberOfChecks: value,
},
});
},
[ruleParams.condition, setRuleParams]
);
if (!useTimeWindow) {
return (
<PopoverExpression
value={
(numberOfChecks ?? 5) +
i18n.translate('xpack.synthetics.windowValueExpression.checksLabel', {
defaultMessage: ' checks',
})
}
>
<EuiPopoverTitle>
{i18n.translate(
'xpack.synthetics.windowValueExpression.numberOfChecksPopoverTitleLabel',
{ defaultMessage: 'Number of checks' }
)}
</EuiPopoverTitle>
<EuiFieldNumber
data-test-subj="syntheticsWindowValueExpressionFieldNumber"
min={1}
max={10}
compressed
value={numberOfChecks ?? 5}
onChange={(evt) => onNumberOfChecksChange(Number(evt.target.value))}
/>
</PopoverExpression>
);
}
return (
<ForLastExpression
timeWindowSize={timeWindowSize}
timeWindowUnit={timeWindowUnit}
onChangeWindowSize={(val) => {
onTimeWindowChange({ size: val ?? 5, unit: timeWindowUnit });
}}
onChangeWindowUnit={(val) => {
onTimeWindowChange({ size: timeWindowSize, unit: (val ?? 'm') as TIME_UNITS });
}}
errors={{}}
description=""
/>
);
};

View file

@ -0,0 +1,124 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { useFetchSyntheticsSuggestions } from '../hooks/use_fetch_synthetics_suggestions';
import { StatusRuleParamsProps } from '../status_rule_ui';
import { LocationsField, MonitorField, MonitorTypeField, ProjectsField, TagsField } from './fields';
type FieldKeys = 'monitorIds' | 'projects' | 'tags' | 'locations' | 'monitorTypes';
interface Props {
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}
export const FieldFilters = ({ ruleParams, setRuleParams }: Props) => {
const [search, setSearch] = useState<string>('');
const [selectedField, setSelectedField] = useState<string>();
const {
suggestions = [],
isLoading,
allSuggestions,
} = useFetchSyntheticsSuggestions({
search,
fieldName: selectedField,
});
const onFieldChange = useCallback(
(key: FieldKeys, value?: string[]) => {
setRuleParams(key, value);
},
[setRuleParams]
);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<MonitorField
onChange={(val) => {
onFieldChange('monitorIds', val);
}}
value={ruleParams.monitorIds}
setSearch={setSearch}
suggestions={suggestions}
allSuggestions={allSuggestions}
isLoading={isLoading}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
<EuiFlexItem>
<MonitorTypeField
onChange={(val) => {
onFieldChange('monitorTypes', val);
}}
value={ruleParams.monitorTypes}
setSearch={setSearch}
suggestions={suggestions}
allSuggestions={allSuggestions}
isLoading={isLoading}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<TagsField
onChange={(val) => {
onFieldChange('tags', val);
}}
value={ruleParams.tags}
setSearch={setSearch}
suggestions={suggestions}
allSuggestions={allSuggestions}
isLoading={isLoading}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
<EuiFlexItem>
<ProjectsField
onChange={(val) => {
onFieldChange('projects', val);
}}
value={ruleParams.projects}
setSearch={setSearch}
suggestions={suggestions}
allSuggestions={allSuggestions}
isLoading={isLoading}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<LocationsField
onChange={(val) => {
onFieldChange('locations', val);
}}
value={ruleParams.locations}
setSearch={setSearch}
suggestions={suggestions}
allSuggestions={allSuggestions}
isLoading={isLoading}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
);
};

View file

@ -0,0 +1,74 @@
/*
* 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 React, { ReactNode } from 'react';
import { EuiExpression, EuiPopover, EuiExpressionProps } from '@elastic/eui';
import { ALL_VALUE } from '@kbn/slo-schema';
import { isEmpty } from 'lodash';
import { allOptionText } from './fields';
import { Suggestion } from '../hooks/use_fetch_synthetics_suggestions';
interface Props {
title?: ReactNode;
value?: string[];
children?: ReactNode;
color?: EuiExpressionProps['color'];
selectedField?: string;
fieldName: string;
setSelectedField: (value?: string) => void;
allSuggestions?: Record<string, Suggestion[]>;
}
export function FieldPopoverExpression({
title,
value,
children,
color,
selectedField,
fieldName,
setSelectedField,
allSuggestions,
}: Props) {
const isPopoverOpen = selectedField === fieldName;
const suggestions = allSuggestions?.[fieldName];
let label =
!isEmpty(value) && value
? suggestions
?.filter((suggestion) => value.includes(suggestion.value))
?.map((suggestion) => suggestion.label)
.join(', ')
: allOptionText;
if (value?.includes(ALL_VALUE)) {
label = allOptionText;
}
const closePopover = () => setSelectedField(selectedField === fieldName ? undefined : fieldName);
return (
<span>
<EuiPopover
isOpen={isPopoverOpen}
anchorPosition="downLeft"
closePopover={closePopover}
button={
<EuiExpression
description={title}
value={label}
isActive={Boolean(selectedField)}
color={color}
onClick={closePopover}
/>
}
repositionOnScroll
>
<div style={{ width: 300 }}>{children}</div>
</EuiPopover>
</span>
);
}

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 { onFieldChange } from './field_selector';
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { ALL_VALUE } from '@kbn/slo-schema';
describe('onFieldChange', () => {
let onChangeMock: jest.Mock;
beforeEach(() => {
onChangeMock = jest.fn();
});
it('should filter out ALL_VALUE when a specific value is selected', () => {
const selected: Array<EuiComboBoxOptionOption<string>> = [
{ label: 'Option 2', value: ALL_VALUE },
{ label: 'Option 1', value: 'value1' },
];
onFieldChange(selected, onChangeMock);
expect(onChangeMock).toHaveBeenCalledWith(['value1']);
});
it('should return an empty array when ALL_VALUE is selected', () => {
const selected: Array<EuiComboBoxOptionOption<string>> = [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: ALL_VALUE },
];
onFieldChange(selected, onChangeMock);
expect(onChangeMock).toHaveBeenCalledWith([]);
});
it('should return an empty array when selected is empty', () => {
const selected: Array<EuiComboBoxOptionOption<string>> = [];
onFieldChange(selected, onChangeMock);
expect(onChangeMock).toHaveBeenCalledWith([]);
});
it('should call onChange with the filtered array when no ALL_VALUE is present', () => {
const selected: Array<EuiComboBoxOptionOption<string>> = [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: 'value2' },
];
onFieldChange(selected, onChangeMock);
expect(onChangeMock).toHaveBeenCalledWith(['value1', 'value2']);
});
it('should return an empty array if the last selected option is ALL_VALUE', () => {
const selected: Array<EuiComboBoxOptionOption<string>> = [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: ALL_VALUE },
];
onFieldChange(selected, onChangeMock);
expect(onChangeMock).toHaveBeenCalledWith([]);
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 React from 'react';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
import { ALL_VALUE } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import { i18n } from '@kbn/i18n';
import { Suggestion } from '../hooks/use_fetch_synthetics_suggestions';
interface Option {
label: string;
value: string;
}
export interface Props {
allowAllOption?: boolean;
dataTestSubj: string;
fieldName: 'monitorIds' | 'projects' | 'tags' | 'locations' | 'monitorTypes';
suggestions?: Suggestion[];
isLoading?: boolean;
required?: boolean;
value?: string[];
onChange: (selected: string[]) => void;
placeholder: string;
setSearch: (val: string) => void;
setSelectedField: (value: string) => void;
}
const ALL_OPTION = {
label: i18n.translate('xpack.synthetics.filter.alert.allLabel', {
defaultMessage: 'All',
}),
value: ALL_VALUE,
};
export function FieldSelector({
allowAllOption = true,
dataTestSubj,
value,
onChange,
isLoading,
placeholder,
suggestions,
setSearch,
}: Props) {
const options = (allowAllOption ? [ALL_OPTION] : []).concat(createOptions(suggestions));
const debouncedSearch = debounce((val) => setSearch(val), 200);
return (
<EuiFormRow fullWidth>
<EuiComboBox
async
placeholder={placeholder}
data-test-subj={dataTestSubj}
isClearable
fullWidth
isLoading={isLoading}
onChange={(selected: Array<EuiComboBoxOptionOption<string>>) => {
onFieldChange(selected, onChange);
}}
onSearchChange={(val: string) => debouncedSearch(val)}
options={options}
selectedOptions={value?.map((val) => {
const option = options.find((opt) => opt.value === val);
if (option) {
return {
value: val,
label: option.label,
'data-test-subj': `${dataTestSubj}SelectedValue`,
};
}
return {
value: val,
label: val,
'data-test-subj': `${dataTestSubj}SelectedValue`,
};
})}
/>
</EuiFormRow>
);
}
export const onFieldChange = (
selected: Array<EuiComboBoxOptionOption<string>>,
onChange: (selected: string[]) => void
) => {
// removes ALL value option if a specific value is selected
if (selected.length && selected.at(-1)?.value !== ALL_VALUE) {
onChange(selected.filter((val) => val.value !== ALL_VALUE).map((val) => val.value!));
return;
}
// removes specific value if ALL value is selected
if (selected.length && selected.at(-1)?.value === ALL_VALUE) {
onChange([]);
return;
}
onChange([]);
};
function createOptions(suggestions: Suggestion[] = []): Option[] {
return suggestions
.map((suggestion) => ({ label: suggestion.label, value: suggestion.value }))
.sort((a, b) => String(a.label).localeCompare(b.label));
}

View file

@ -0,0 +1,159 @@
/*
* 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 React from 'react';
import { FieldPopoverExpression } from './field_popover_expression';
import { Suggestion } from '../hooks/use_fetch_synthetics_suggestions';
import { FieldSelector } from './field_selector';
interface FieldProps {
value?: string[];
onChange: (value?: string[]) => void;
setSearch: (val: string) => void;
suggestions?: Suggestion[];
allSuggestions?: Record<string, Suggestion[]>;
isLoading?: boolean;
setSelectedField: (value?: string) => void;
selectedField?: string;
}
export const allOptionText = i18n.translate('xpack.synthetics.filter.alert.allLabel', {
defaultMessage: 'All',
});
export function MonitorField({ value, onChange, ...rest }: FieldProps) {
return (
<FieldPopoverExpression
value={value}
title={i18n.translate('xpack.synthetics.alerting.fields.monitor', {
defaultMessage: 'Monitor',
})}
fieldName="monitorIds"
selectedField={rest.selectedField}
setSelectedField={rest.setSelectedField}
allSuggestions={rest.allSuggestions}
>
<FieldSelector
value={value}
fieldName="monitorIds"
onChange={onChange}
placeholder={i18n.translate('xpack.synthetics.monitorNamesSelectPlaceholder', {
defaultMessage: 'Select monitor name',
})}
dataTestSubj="monitorNameField"
{...rest}
/>
</FieldPopoverExpression>
);
}
export function TagsField({ value, onChange, ...rest }: FieldProps) {
return (
<FieldPopoverExpression
value={value}
title={i18n.translate('xpack.synthetics.alerting.fields.tags', {
defaultMessage: 'Tags',
})}
fieldName="tags"
selectedField={rest.selectedField}
setSelectedField={rest.setSelectedField}
allSuggestions={rest.allSuggestions}
>
<FieldSelector
value={value}
onChange={onChange}
placeholder={i18n.translate('xpack.synthetics.tagsSelectPlaceholder', {
defaultMessage: 'Select tags',
})}
dataTestSubj="tagsField"
fieldName="tags"
{...rest}
/>
</FieldPopoverExpression>
);
}
export function MonitorTypeField({ value, onChange, ...rest }: FieldProps) {
const label = i18n.translate('xpack.synthetics.alerting.fields.type', {
defaultMessage: 'Type',
});
return (
<FieldPopoverExpression
value={value}
title={label}
fieldName="monitorTypes"
selectedField={rest.selectedField}
setSelectedField={rest.setSelectedField}
allSuggestions={rest.allSuggestions}
>
<FieldSelector
value={value}
onChange={onChange}
placeholder={i18n.translate('xpack.synthetics.monitorTypesSelectPlaceholder', {
defaultMessage: 'Select monitor type',
})}
dataTestSubj="monitorTypeField"
fieldName="monitorTypes"
{...rest}
/>
</FieldPopoverExpression>
);
}
export function LocationsField({ value, onChange, ...rest }: FieldProps) {
const label = i18n.translate('xpack.synthetics.alerting.fields.location', {
defaultMessage: 'Locations',
});
return (
<FieldPopoverExpression
value={value}
title={label}
fieldName="locations"
selectedField={rest.selectedField}
setSelectedField={rest.setSelectedField}
allSuggestions={rest.allSuggestions}
>
<FieldSelector
value={value}
onChange={onChange}
placeholder={i18n.translate('xpack.synthetics.locationSelectPlaceholder', {
defaultMessage: 'Select location',
})}
dataTestSubj="monitorLocationField"
fieldName="locations"
{...rest}
/>
</FieldPopoverExpression>
);
}
export function ProjectsField({ value, onChange, ...rest }: FieldProps) {
const label = i18n.translate('xpack.synthetics.alerting.fields.project', {
defaultMessage: 'Projects',
});
return (
<FieldPopoverExpression
value={value}
title={label}
fieldName="projects"
selectedField={rest.selectedField}
setSelectedField={rest.setSelectedField}
allSuggestions={rest.allSuggestions}
>
<FieldSelector
value={value}
onChange={onChange}
placeholder={i18n.translate('xpack.synthetics.projectSelectPlaceholder', {
defaultMessage: 'Select project',
})}
dataTestSubj="monitorProjectField"
fieldName="projects"
{...rest}
/>
</FieldPopoverExpression>
);
}

View file

@ -0,0 +1,172 @@
/*
* 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 { EuiExpression, EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { getConditionType, StatusRuleCondition } from '../../../../../../common/rules/status_rule';
import { StatusRuleParamsProps } from '../status_rule_ui';
interface Props {
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}
export const WITHIN_TOTAL_CHECKS_LABEL = i18n.translate(
'xpack.synthetics.monitorStatusRule.withinTotalChecks.label',
{
defaultMessage: 'Within total checks',
}
);
export const WITHIN_TOTAL_CHECKS_EXPRESSION = i18n.translate(
'xpack.synthetics.monitorStatusRule.withinTotalChecks.expression',
{
defaultMessage: 'Within the last',
}
);
export const WITHIN_TIMERANGE_EXPRESSION = i18n.translate(
'xpack.synthetics.monitorStatusRule.withinTimerange.expression',
{
defaultMessage: 'Within the last',
}
);
export const WITHIN_TIMERANGE_LABEL = i18n.translate(
'xpack.synthetics.monitorStatusRule.withinTimerange.label',
{
defaultMessage: 'Within timerange',
}
);
interface Option {
label: string;
key: 'checksWindow' | 'timeWindow' | 'locations';
}
const OPTIONS: Option[] = [
{
label: WITHIN_TOTAL_CHECKS_LABEL,
key: 'checksWindow',
},
{
label: WITHIN_TIMERANGE_LABEL,
key: 'timeWindow',
},
];
export const DEFAULT_CONDITION = {
window: { numberOfChecks: 5 },
groupBy: 'locationId',
downThreshold: 3,
locationsThreshold: 1,
};
const getCheckedOption = (option: Option, condition?: StatusRuleCondition) => {
const { useTimeWindow, isLocationBased } = getConditionType(condition);
if (isLocationBased && option.key === 'locations') {
return 'on';
}
if (option.key === 'timeWindow' && useTimeWindow && !isLocationBased) {
return 'on';
}
if (option.key === 'checksWindow' && !useTimeWindow && !isLocationBased) {
return 'on';
}
return undefined;
};
export const ForTheLastExpression = ({ ruleParams, setRuleParams }: Props) => {
const { condition } = ruleParams;
const { useTimeWindow } = getConditionType(condition);
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<Option[]>(OPTIONS);
useEffect(() => {
if (!condition) {
setRuleParams('condition', DEFAULT_CONDITION);
}
}, [condition, setRuleParams]);
useEffect(() => {
setOptions(
OPTIONS.map((option) => ({
key: option.key as 'checksWindow' | 'timeWindow',
label: option.label,
checked: getCheckedOption(option, condition),
}))
);
}, [condition, useTimeWindow]);
const getDescriptiveText = () => {
if (useTimeWindow) {
return WITHIN_TIMERANGE_EXPRESSION;
}
return WITHIN_TOTAL_CHECKS_EXPRESSION;
};
return (
<EuiPopover
id="checkPopover"
panelPaddingSize="s"
button={
<EuiExpression
description={getDescriptiveText()}
isActive={isOpen}
onClick={() => setIsOpen(!isOpen)}
/>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
anchorPosition="downLeft"
>
<EuiSelectable<Option>
singleSelection="always"
options={options}
onChange={(selectedValues) => {
const selectedValue = selectedValues.filter((v) => v.checked === 'on')?.[0];
switch (selectedValue?.key) {
case 'checksWindow':
setRuleParams('condition', {
...ruleParams.condition,
downThreshold: 5,
locationsThreshold: 1,
window: { numberOfChecks: 5 },
});
break;
case 'timeWindow':
setRuleParams('condition', {
...ruleParams.condition,
downThreshold: 5,
locationsThreshold: 1,
window: { time: { unit: 'm', size: 5 } },
});
break;
default:
break;
}
}}
>
{(list) => (
<div style={{ width: 240 }}>
<EuiPopoverTitle>
{i18n.translate('xpack.synthetics.forTheLastExpression.whenPopoverTitleLabel', {
defaultMessage: 'When',
})}
</EuiPopoverTitle>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
);
};

View file

@ -0,0 +1,55 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSwitch } from '@elastic/eui';
export const GroupByExpression = ({
onChange,
groupByLocation,
locationsThreshold,
}: {
locationsThreshold: number;
groupByLocation: boolean;
onChange: (val: boolean) => void;
}) => {
const disabledGroupBy = locationsThreshold > 1;
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSwitch
compressed
disabled={disabledGroupBy}
label={i18n.translate('xpack.synthetics.groupByExpression.euiSwitch.groupByLabel', {
defaultMessage: 'Receive distinct alerts for each location',
})}
checked={groupByLocation}
onChange={(e) => onChange(e.target.checked)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{disabledGroupBy ? (
<EuiIconTip
content={i18n.translate('xpack.synthetics.groupByExpression.euiSwitch.tooltip', {
defaultMessage:
'When locations threshold is greater than 1, group by location is disabled.',
})}
/>
) : (
<EuiIconTip
content={i18n.translate('xpack.synthetics.groupByExpression.euiSwitch.tooltip', {
defaultMessage:
'When the monitor detects a failure on one or more locations, you receive an alert for each of these locations, instead of a single alert.',
})}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 React, { useState, ReactNode } from 'react';
import { EuiExpression, EuiPopover, EuiExpressionProps } from '@elastic/eui';
interface Props {
title?: ReactNode;
value: ReactNode;
children?: ReactNode;
color?: EuiExpressionProps['color'];
}
export function PopoverExpression(props: Props) {
const { title, value, children, color } = props;
const [popoverOpen, setPopoverOpen] = useState(false);
return (
<EuiPopover
isOpen={popoverOpen}
anchorPosition="downLeft"
closePopover={() => setPopoverOpen(false)}
button={
<EuiExpression
description={title}
value={value}
isActive={popoverOpen}
color={color}
onClick={() => setPopoverOpen((state) => !state)}
/>
}
repositionOnScroll
>
{children}
</EuiPopover>
);
}

View file

@ -21,12 +21,6 @@ export const ToggleFlyoutTranslations = {
toggleTlsAriaLabel: i18n.translate('xpack.synthetics.toggleAlertFlyout.tls.ariaLabel', {
defaultMessage: 'Open add tls rule flyout',
}),
toggleMonitorStatusContent: i18n.translate('xpack.synthetics.toggleAlertButton.content', {
defaultMessage: 'Monitor status rule',
}),
toggleTlsContent: i18n.translate('xpack.synthetics.toggleTlsAlertButton.label.content', {
defaultMessage: 'TLS certificate rule',
}),
navigateToAlertingUIAriaLabel: i18n.translate('xpack.synthetics.app.navigateToAlertingUi', {
defaultMessage: 'Leave Synthetics and go to Alerting Management page',
}),
@ -40,3 +34,11 @@ export const ToggleFlyoutTranslations = {
defaultMessage: 'Alerts and rules',
}),
};
export const TLS_RULE_NAME = i18n.translate('xpack.synthetics.toggleTlsAlertButton.label.content', {
defaultMessage: 'TLS certificate rule',
});
export const STATUS_RULE_NAME = i18n.translate('xpack.synthetics.toggleAlertButton.content', {
defaultMessage: 'Monitor status rule',
});

View file

@ -0,0 +1,66 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import { ClientPluginsStart } from '../../../../../plugin';
export interface Suggestion {
label: string;
value: string;
count: number;
}
export interface UseFetchSyntheticsSuggestions {
suggestions: Suggestion[];
isLoading: boolean;
allSuggestions?: Record<string, Suggestion[]>;
}
export interface Params {
fieldName?: string;
filters?: {
locations?: string[];
monitorIds?: string[];
tags?: string[];
projects?: string[];
};
search: string;
}
type ApiResponse = Record<string, Suggestion[]>;
export function useFetchSyntheticsSuggestions({
filters,
fieldName,
search,
}: Params): UseFetchSyntheticsSuggestions {
const { http } = useKibana<ClientPluginsStart>().services;
const { locations, monitorIds, tags, projects } = filters || {};
const { loading, data } = useFetcher(
async ({ signal }) => {
return await http.get<ApiResponse>('/internal/synthetics/suggestions', {
query: {
locations: locations || [],
monitorQueryIds: monitorIds || [],
tags: tags || [],
projects: projects || [],
query: search,
},
signal,
});
},
[http, locations, monitorIds, tags, projects, search]
);
return {
suggestions: fieldName ? data?.[fieldName] ?? [] : [],
allSuggestions: data,
isLoading: Boolean(loading),
};
}

View file

@ -8,6 +8,8 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useEffect, useMemo } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { selectDynamicSettings } from '../../../state/settings';
import { useSyntheticsSettingsContext } from '../../../contexts';
import {
selectSyntheticsAlerts,
@ -20,6 +22,7 @@ import {
import { SYNTHETICS_TLS_RULE } from '../../../../../../common/constants/synthetics_alerts';
import {
selectAlertFlyoutVisibility,
selectIsNewRule,
selectMonitorListState,
setAlertFlyoutVisible,
} from '../../../state';
@ -31,12 +34,16 @@ export const useSyntheticsRules = (isOpen: boolean) => {
const defaultRules = useSelector(selectSyntheticsAlerts);
const loading = useSelector(selectSyntheticsAlertsLoading);
const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility);
const isNewRule = useSelector(selectIsNewRule);
const { settings } = useSelector(selectDynamicSettings);
const { canSave } = useSyntheticsSettingsContext();
const { loaded, data: monitors } = useSelector(selectMonitorListState);
const hasMonitors = loaded && monitors.absoluteTotal && monitors.absoluteTotal > 0;
const defaultRulesEnabled =
settings && (settings?.defaultStatusRuleEnabled || settings?.defaultTLSRuleEnabled);
const getOrCreateAlerts = useCallback(() => {
if (canSave) {
@ -47,7 +54,7 @@ export const useSyntheticsRules = (isOpen: boolean) => {
}, [canSave, dispatch]);
useEffect(() => {
if (hasMonitors) {
if (hasMonitors && defaultRulesEnabled) {
if (!defaultRules) {
// on initial load we prioritize loading the app
setTimeout(() => {
@ -59,22 +66,52 @@ export const useSyntheticsRules = (isOpen: boolean) => {
}
// we don't want to run this on defaultRules change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, isOpen, hasMonitors]);
}, [dispatch, isOpen, hasMonitors, defaultRulesEnabled]);
const { triggersActionsUi } = useKibana<ClientPluginsStart>().services;
const EditAlertFlyout = useMemo(() => {
const initialRule =
alertFlyoutVisible === SYNTHETICS_TLS_RULE ? defaultRules?.tlsRule : defaultRules?.statusRule;
if (!initialRule) {
if (!initialRule || isNewRule) {
return null;
}
return triggersActionsUi.getEditRuleFlyout({
onClose: () => dispatch(setAlertFlyoutVisible(null)),
hideInterval: true,
initialRule,
});
}, [defaultRules, dispatch, triggersActionsUi, alertFlyoutVisible]);
}, [
alertFlyoutVisible,
defaultRules?.tlsRule,
defaultRules?.statusRule,
isNewRule,
triggersActionsUi,
dispatch,
]);
return useMemo(() => ({ loading, EditAlertFlyout }), [EditAlertFlyout, loading]);
const NewRuleFlyout = useMemo(() => {
if (!isNewRule || !alertFlyoutVisible) {
return null;
}
return triggersActionsUi.getAddRuleFlyout({
consumer: 'uptime',
ruleTypeId: alertFlyoutVisible,
onClose: () => dispatch(setAlertFlyoutVisible(null)),
initialValues: {
name:
alertFlyoutVisible === SYNTHETICS_TLS_RULE
? i18n.translate('xpack.synthetics.alerting.defaultRuleName.tls', {
defaultMessage: 'Synthetics monitor TLS rule',
})
: i18n.translate('xpack.synthetics.alerting.defaultRuleName', {
defaultMessage: 'Synthetics monitor status rule',
}),
},
});
}, [isNewRule, triggersActionsUi, dispatch, alertFlyoutVisible]);
return useMemo(
() => ({ loading, EditAlertFlyout, NewRuleFlyout }),
[EditAlertFlyout, loading, NewRuleFlyout]
);
};

View file

@ -0,0 +1,77 @@
/*
* 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 React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Filter } from '@kbn/es-query';
import { EuiFormRow } from '@elastic/eui';
import { useSyntheticsDataView } from '../../contexts/synthetics_data_view_context';
import { ClientPluginsStart } from '../../../../plugin';
export function AlertSearchBar({
kqlQuery,
onChange,
}: {
kqlQuery: string;
onChange: (val: { kqlQuery?: string; filters?: Filter[] }) => void;
}) {
const {
data: { query },
unifiedSearch: {
ui: { QueryStringInput },
},
} = useKibana<ClientPluginsStart>().services;
const dataView = useSyntheticsDataView();
useEffect(() => {
const sub = query.state$.subscribe(() => {
const queryState = query.getState();
onChange({
kqlQuery: String(queryState.query),
});
});
return sub.unsubscribe;
}, [onChange, query]);
return (
<EuiFormRow
label={i18n.translate('xpack.synthetics.list.search.title', {
defaultMessage: 'Filter by',
})}
fullWidth
>
<QueryStringInput
appName="synthetics"
iconType="search"
placeholder={PLACEHOLDER}
indexPatterns={dataView ? [dataView] : []}
onChange={(queryN) => {
onChange({
kqlQuery: String(queryN.query),
});
}}
onSubmit={(queryN) => {
if (queryN) {
onChange({
kqlQuery: String(queryN.query),
});
}
}}
query={{ query: String(kqlQuery), language: 'kuery' }}
autoSubmit={true}
disableLanguageSwitcher={true}
/>
</EuiFormRow>
);
}
const PLACEHOLDER = i18n.translate('xpack.synthetics.list.search', {
defaultMessage: 'Filter by KQL query',
});

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
export const RuleNameWithLoading = ({
ruleName,
isLoading,
}: {
ruleName: string;
isLoading: boolean;
}) => {
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>{ruleName}</EuiFlexItem>
{isLoading && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,157 @@
/*
* 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 {
EuiExpression,
EuiFlexItem,
EuiFlexGroup,
EuiSpacer,
EuiTitle,
EuiHorizontalRule,
EuiIconTip,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { ValueExpression } from '@kbn/triggers-actions-ui-plugin/public';
import { i18n } from '@kbn/i18n';
import { GroupByExpression } from './common/group_by_field';
import { WindowValueExpression } from './common/condition_window_value';
import { DEFAULT_CONDITION, ForTheLastExpression } from './common/for_the_last_expression';
import { StatusRuleParamsProps } from './status_rule_ui';
import { LocationsValueExpression } from './common/condition_locations_value';
interface Props {
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}
export const StatusRuleExpression: React.FC<Props> = ({ ruleParams, setRuleParams }) => {
const condition = ruleParams.condition ?? DEFAULT_CONDITION;
const downThreshold = condition?.downThreshold ?? DEFAULT_CONDITION.downThreshold;
const locationsThreshold = condition?.locationsThreshold ?? DEFAULT_CONDITION.locationsThreshold;
const onThresholdChange = useCallback(
(value: number) => {
const prevCondition = ruleParams.condition ?? DEFAULT_CONDITION;
setRuleParams('condition', {
...prevCondition,
downThreshold: value,
});
},
[ruleParams.condition, setRuleParams]
);
const onGroupByChange = useCallback(
(groupByLocation: boolean) => {
setRuleParams('condition', {
...(ruleParams?.condition ?? DEFAULT_CONDITION),
groupBy: groupByLocation ? 'locationId' : 'none',
});
},
[ruleParams?.condition, setRuleParams]
);
return (
<>
<EuiHorizontalRule size="half" margin="xs" />
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>
{i18n.translate('xpack.synthetics.rules.status.condition.title', {
defaultMessage: 'Condition',
})}
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
content={i18n.translate('xpack.synthetics.rule.condition.retests', {
defaultMessage: 'Retests are included in the number of checks.',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiExpression
aria-label={StatusTranslations.criteriaAriaLabel}
color="success"
description={StatusTranslations.criteriaDescription}
value={StatusTranslations.criteriaValue}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ValueExpression
value={downThreshold}
valueLabel={i18n.translate('xpack.synthetics.rules.status.valueLabel', {
defaultMessage: '{threshold} times',
values: { threshold: downThreshold },
})}
onChangeSelectedValue={(val) => {
onThresholdChange(val);
}}
description={StatusTranslations.isDownDescription}
errors={[]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ForTheLastExpression ruleParams={ruleParams} setRuleParams={setRuleParams} />
</EuiFlexItem>
<EuiFlexItem>
<WindowValueExpression ruleParams={ruleParams} setRuleParams={setRuleParams} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiExpression description={StatusTranslations.fromLocationsDescription} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LocationsValueExpression ruleParams={ruleParams} setRuleParams={setRuleParams} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<GroupByExpression
groupByLocation={ruleParams.condition?.groupBy === 'locationId'}
onChange={onGroupByChange}
locationsThreshold={locationsThreshold}
/>
<EuiSpacer size="l" />
</>
);
};
export const StatusTranslations = {
criteriaAriaLabel: i18n.translate('xpack.synthetics.rules.status.criteriaExpression.ariaLabel', {
defaultMessage:
'An expression displaying the criteria for the monitors that are being watched by this alert',
}),
criteriaDescription: i18n.translate(
'xpack.synthetics.alerts.tls.criteriaExpression.description',
{
defaultMessage: 'when',
}
),
criteriaValue: i18n.translate('xpack.synthetics.status.criteriaExpression.value', {
defaultMessage: 'monitor',
}),
isDownDescription: i18n.translate('xpack.synthetics.status.expirationExpression.description', {
defaultMessage: 'is down ',
}),
fromLocationsDescription: i18n.translate(
'xpack.synthetics.status.locationsThreshold.description',
{
defaultMessage: 'from at least',
}
),
};

View file

@ -0,0 +1,39 @@
/*
* 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 React, { useCallback } from 'react';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { Filter } from '@kbn/es-query';
import { EuiSpacer } from '@elastic/eui';
import { FieldFilters } from './common/field_filters';
import { AlertSearchBar } from './query_bar';
import { StatusRuleExpression } from './status_rule_expression';
import { StatusRuleParams } from '../../../../../common/rules/status_rule';
export type StatusRuleParamsProps = RuleTypeParamsExpressionProps<StatusRuleParams>;
export const StatusRuleComponent: React.FC<{
ruleParams: StatusRuleParamsProps['ruleParams'];
setRuleParams: StatusRuleParamsProps['setRuleParams'];
}> = ({ ruleParams, setRuleParams }) => {
const onFiltersChange = useCallback(
(val: { kqlQuery?: string; filters?: Filter[] }) => {
setRuleParams('kqlQuery', val.kqlQuery);
},
[setRuleParams]
);
return (
<>
<AlertSearchBar kqlQuery={ruleParams.kqlQuery ?? ''} onChange={onFiltersChange} />
<EuiSpacer size="m" />
<FieldFilters ruleParams={ruleParams} setRuleParams={setRuleParams} />
<StatusRuleExpression ruleParams={ruleParams} setRuleParams={setRuleParams} />
<EuiSpacer size="m" />
</>
);
};

View file

@ -11,21 +11,18 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiContextMenuPanelItemDescriptor,
EuiFlexGroup,
EuiFlexItem,
EuiHeaderLink,
EuiLoadingSpinner,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RuleNameWithLoading } from './rule_name_with_loading';
import {
SYNTHETICS_STATUS_RULE,
SYNTHETICS_TLS_RULE,
} from '../../../../../common/constants/synthetics_alerts';
import { ManageRulesLink } from '../common/links/manage_rules_link';
import { ClientPluginsStart } from '../../../../plugin';
import { ToggleFlyoutTranslations } from './hooks/translations';
import { STATUS_RULE_NAME, TLS_RULE_NAME, ToggleFlyoutTranslations } from './hooks/translations';
import { useSyntheticsRules } from './hooks/use_synthetics_rules';
import {
selectAlertFlyoutVisibility,
@ -40,67 +37,84 @@ export const ToggleAlertFlyoutButton = () => {
const { application } = useKibana<ClientPluginsStart>().services;
const hasUptimeWrite = application?.capabilities.uptime?.save ?? false;
const { EditAlertFlyout, loading } = useSyntheticsRules(isOpen);
const { EditAlertFlyout, loading, NewRuleFlyout } = useSyntheticsRules(isOpen);
const { loaded, data: monitors } = useSelector(selectMonitorListState);
const hasMonitors = loaded && monitors.absoluteTotal && monitors.absoluteTotal > 0;
const monitorStatusAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'xpack.synthetics.toggleAlertFlyout',
name: (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>{ToggleFlyoutTranslations.toggleMonitorStatusContent}</EuiFlexItem>
{loading && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
)}
</EuiFlexGroup>
),
onClick: () => {
dispatch(setAlertFlyoutVisible(SYNTHETICS_STATUS_RULE));
setIsOpen(false);
},
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite || loading,
icon: 'bell',
};
const tlsAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'xpack.synthetics.toggleAlertFlyout.tls',
name: (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>{ToggleFlyoutTranslations.toggleTlsContent}</EuiFlexItem>
{loading && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
)}
</EuiFlexGroup>
),
onClick: () => {
dispatch(setAlertFlyoutVisible(SYNTHETICS_TLS_RULE));
setIsOpen(false);
},
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite || loading,
icon: 'bell',
};
const managementContextItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.navigateToAlertingUIAriaLabel,
'data-test-subj': 'xpack.synthetics.navigateToAlertingUi',
name: <ManageRulesLink />,
icon: 'tableOfContents',
};
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
items: [monitorStatusAlertContextMenuItem, tlsAlertContextMenuItem, managementContextItem],
items: [
{
name: STATUS_RULE_NAME,
'data-test-subj': 'manageStatusRuleName',
panel: 1,
},
{
name: TLS_RULE_NAME,
'data-test-subj': 'manageTlsRuleName',
panel: 2,
},
{
'aria-label': ToggleFlyoutTranslations.navigateToAlertingUIAriaLabel,
'data-test-subj': 'xpack.synthetics.navigateToAlertingUi',
name: <ManageRulesLink />,
icon: 'tableOfContents',
},
],
},
{
id: 1,
items: [
{
name: CREATE_STATUS_RULE,
'data-test-subj': 'createNewStatusRule',
icon: 'plusInCircle',
onClick: () => {
dispatch(setAlertFlyoutVisible({ id: SYNTHETICS_STATUS_RULE, isNewRuleFlyout: true }));
setIsOpen(false);
},
},
{
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'editDefaultStatusRule',
name: <RuleNameWithLoading ruleName={EDIT_STATUS_RULE} isLoading={loading} />,
onClick: () => {
dispatch(setAlertFlyoutVisible({ id: SYNTHETICS_STATUS_RULE, isNewRuleFlyout: false }));
setIsOpen(false);
},
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite || loading,
icon: 'bell',
},
],
},
{
id: 2,
items: [
{
name: CREATE_TLS_RULE_NAME,
'data-test-subj': 'createNewTLSRule',
icon: 'plusInCircle',
onClick: () => {
dispatch(setAlertFlyoutVisible({ id: SYNTHETICS_TLS_RULE, isNewRuleFlyout: true }));
setIsOpen(false);
},
},
{
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'editDefaultTlsRule',
name: <RuleNameWithLoading ruleName={EDIT_TLS_RULE_NAME} isLoading={loading} />,
onClick: () => {
dispatch(setAlertFlyoutVisible({ id: SYNTHETICS_TLS_RULE, isNewRuleFlyout: false }));
setIsOpen(false);
},
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite || loading,
icon: 'bell',
},
],
},
];
@ -113,7 +127,7 @@ export const ToggleAlertFlyoutButton = () => {
<EuiHeaderLink
color="text"
aria-label={ToggleFlyoutTranslations.toggleButtonAriaLabel}
data-test-subj="xpack.synthetics.alertsPopover.toggleButton"
data-test-subj="syntheticsAlertsRulesButton"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsOpen(!isOpen)}
@ -130,6 +144,7 @@ export const ToggleAlertFlyoutButton = () => {
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
{alertFlyoutVisible && EditAlertFlyout}
{alertFlyoutVisible && NewRuleFlyout}
</>
);
};
@ -140,3 +155,31 @@ const noWritePermissionsTooltipContent = i18n.translate(
defaultMessage: 'You do not have sufficient permissions to perform this action.',
}
);
export const EDIT_TLS_RULE_NAME = i18n.translate(
'xpack.synthetics.toggleTlsAlertButton.label.default',
{
defaultMessage: 'Edit default TLS rule',
}
);
export const EDIT_STATUS_RULE = i18n.translate(
'xpack.synthetics.toggleStatusAlertButton.label.default',
{
defaultMessage: 'Edit default status rule',
}
);
export const CREATE_TLS_RULE_NAME = i18n.translate(
'xpack.synthetics.toggleTlsAlertButton.createRule',
{
defaultMessage: 'Create TLS rule',
}
);
export const CREATE_STATUS_RULE = i18n.translate(
'xpack.synthetics.toggleStatusAlertButton.createRule',
{
defaultMessage: 'Create status rule',
}
);

View file

@ -35,6 +35,10 @@ describe('Monitor Detail Flyout', () => {
status: 'up',
type: 'http',
check_group: 'check-group',
timespan: {
gte: 'now-15m',
lt: 'now',
},
},
url: {
full: 'https://www.elastic.co',

View file

@ -97,7 +97,7 @@ export function OverviewStatus({ titleAppend }: { titleAppend?: React.ReactNode
<EuiFlexGroup gutterSize="xl" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiStat
data-test-subj="xpack.uptime.synthetics.overview.status.up"
data-test-subj="syntheticsOverviewUp"
description={upDescription}
reverse
title={title(statusConfig?.up)}
@ -107,7 +107,7 @@ export function OverviewStatus({ titleAppend }: { titleAppend?: React.ReactNode
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiStat
data-test-subj="xpack.uptime.synthetics.overview.status.down"
data-test-subj="syntheticsOverviewDown"
description={downDescription}
reverse
title={title(statusConfig?.down)}

View file

@ -6,13 +6,15 @@
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isEmpty } from 'lodash';
import { StatusRuleComponent } from '../../../components/alerts/status_rule_ui';
import { kibanaService } from '../../../../../utils/kibana_service';
import { ClientPluginsStart } from '../../../../../plugin';
import { store } from '../../../state';
@ -28,16 +30,23 @@ interface Props {
export default function MonitorStatusAlert({ coreStart, plugins, params }: Props) {
kibanaService.coreStart = coreStart;
const queryClient = new QueryClient();
const { ruleParams } = params;
return (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<KibanaContextProvider services={{ ...coreStart, ...plugins }}>
<EuiText>
<FormattedMessage
id="xpack.synthetics.alertRule.monitorStatus.description"
defaultMessage="Manage synthetics monitor status rule actions."
/>
</EuiText>
{params.id && isEmpty(ruleParams) && (
<EuiText>
<FormattedMessage
id="xpack.synthetics.alertRule.monitorStatus.description"
defaultMessage="Manage synthetics monitor status rule actions."
/>
</EuiText>
)}
{(!params.id || !isEmpty(ruleParams)) && (
<StatusRuleComponent ruleParams={ruleParams} setRuleParams={params.setRuleParams} />
)}
<EuiSpacer size="m" />
</KibanaContextProvider>

View file

@ -41,7 +41,7 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({
},
defaultActionMessage,
defaultRecoveryMessage,
requiresAppContext: true,
requiresAppContext: false,
format: ({ fields }) => {
return {
reason: fields[ALERT_REASON] || '',

View file

@ -7,7 +7,7 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { call, put, takeEvery, select, takeLatest, debounce } from 'redux-saga/effects';
import { quietFetchOverviewStatusAction } from '../overview_status';
import { fetchOverviewStatusAction, quietFetchOverviewStatusAction } from '../overview_status';
import { enableDefaultAlertingAction } from '../alert_rules';
import {
ConfigKey,
@ -15,7 +15,7 @@ import {
SyntheticsMonitorWithId,
} from '../../../../../common/runtime_types';
import { kibanaService } from '../../../../utils/kibana_service';
import { MonitorOverviewPageState } from '../overview';
import { MonitorOverviewPageState, selectOverviewPageState } from '../overview';
import { selectOverviewState } from '../overview/selectors';
import { fetchEffectFactory, sendErrorToast, sendSuccessToast } from '../utils/fetch_effect';
import { serializeHttpFetchError } from '../utils/http_error';
@ -53,7 +53,13 @@ export function* enableMonitorAlertEffect() {
try {
const response = yield call(fetchUpsertMonitor, action.payload);
yield put(enableMonitorAlertAction.success(response as SyntheticsMonitorWithId));
const pageState = (yield select(selectOverviewPageState)) as MonitorOverviewPageState;
sendSuccessToast(action.payload.success);
yield put(
fetchOverviewStatusAction.get({
pageState,
})
);
if (
(response as EncryptedSyntheticsSavedMonitor)[ConfigKey.ALERT_CONFIG]?.status?.enabled
) {

View file

@ -7,13 +7,7 @@
import { createReducer } from '@reduxjs/toolkit';
import { enableMonitorAlertAction } from '../monitor_list/actions';
import { isStatusEnabled } from '../../../../../common/runtime_types/monitor_management/alert_config';
import {
ConfigKey,
OverviewStatusMetaData,
OverviewStatusState,
} from '../../../../../common/runtime_types';
import { OverviewStatusMetaData, OverviewStatusState } from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '..';
import {
clearOverviewStatusErrorAction,
@ -27,7 +21,6 @@ export interface OverviewStatusStateReducer {
status: OverviewStatusState | null;
allConfigs?: OverviewStatusMetaData[];
disabledConfigs?: OverviewStatusMetaData[];
sortedByStatus?: OverviewStatusMetaData[];
error: IHttpSerializedFetchError | null;
}
@ -63,24 +56,6 @@ export const overviewStatusReducer = createReducer(initialState, (builder) => {
state.error = action.payload;
state.loading = false;
})
.addCase(enableMonitorAlertAction.success, (state, action) => {
const monitorObject = action.payload;
if (!('errors' in monitorObject)) {
const isStatusAlertEnabled = isStatusEnabled(monitorObject[ConfigKey.ALERT_CONFIG]);
state.allConfigs = state.allConfigs?.map((monitor) => {
if (
monitor.configId === monitorObject[ConfigKey.CONFIG_ID] ||
monitor.monitorQueryId === monitorObject[ConfigKey.MONITOR_QUERY_ID]
) {
return {
...monitor,
isStatusAlertEnabled,
};
}
return monitor;
});
}
})
.addCase(clearOverviewStatusErrorAction, (state) => {
state.error = null;
});

View file

@ -16,9 +16,10 @@ export interface PopoverState {
open: boolean;
}
export const setAlertFlyoutVisible = createAction<
typeof SYNTHETICS_STATUS_RULE | typeof SYNTHETICS_TLS_RULE | null
>('[UI] TOGGLE ALERT FLYOUT');
export const setAlertFlyoutVisible = createAction<{
id: typeof SYNTHETICS_STATUS_RULE | typeof SYNTHETICS_TLS_RULE | null;
isNewRuleFlyout: boolean;
} | null>('[UI] TOGGLE ALERT FLYOUT');
export const setBasePath = createAction<string>('[UI] SET BASE PATH');

View file

@ -22,7 +22,8 @@ import {
} from './actions';
export interface UiState {
alertFlyoutVisible: typeof SYNTHETICS_TLS_RULE | typeof SYNTHETICS_STATUS_RULE | null;
ruleFlyoutVisible: typeof SYNTHETICS_TLS_RULE | typeof SYNTHETICS_STATUS_RULE | null;
isNewRuleFlyout?: boolean | null;
basePath: string;
esKuery: string;
searchText: string;
@ -31,7 +32,8 @@ export interface UiState {
}
const initialState: UiState = {
alertFlyoutVisible: null,
isNewRuleFlyout: false,
ruleFlyoutVisible: null,
basePath: '',
esKuery: '',
searchText: '',
@ -45,7 +47,8 @@ export const uiReducer = createReducer(initialState, (builder) => {
state.integrationsPopoverOpen = action.payload;
})
.addCase(setAlertFlyoutVisible, (state, action) => {
state.alertFlyoutVisible = action.payload;
state.ruleFlyoutVisible = action.payload?.id ?? null;
state.isNewRuleFlyout = action.payload?.isNewRuleFlyout ?? null;
})
.addCase(setBasePath, (state, action) => {
state.basePath = action.payload;

View file

@ -12,5 +12,10 @@ const uiStateSelector = (appState: SyntheticsAppState) => appState.ui;
export const selectAlertFlyoutVisibility = createSelector(
uiStateSelector,
({ alertFlyoutVisible }) => alertFlyoutVisible
({ ruleFlyoutVisible }) => ruleFlyoutVisible
);
export const selectIsNewRule = createSelector(
uiStateSelector,
({ isNewRuleFlyout }) => isNewRuleFlyout
);

View file

@ -24,7 +24,7 @@ import { MonitorDetailsState } from '../../../state';
*/
export const mockState: SyntheticsAppState = {
ui: {
alertFlyoutVisible: null,
ruleFlyoutVisible: null,
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
require('@kbn/babel-register').install();
require('./tasks/generate_monitors').generateMonitors();

View file

@ -0,0 +1,97 @@
/*
* 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 axios from 'axios';
import moment from 'moment';
const UP_MONITORS = 0;
const DOWN_MONITORS = 10;
export const generateMonitors = async () => {
// eslint-disable-next-line no-console
console.log(`Generating ${UP_MONITORS} up monitors`);
for (let i = 0; i < UP_MONITORS; i++) {
await createMonitor(getHttpMonitor());
}
// eslint-disable-next-line no-console
console.log(`Generating ${DOWN_MONITORS} down monitors`);
for (let i = 0; i < DOWN_MONITORS; i++) {
await createMonitor(getHttpMonitor(true));
}
};
const createMonitor = async (monitor: any) => {
await axios
.request({
data: monitor,
method: 'post',
url: 'http://127.0.0.1:5601/test/api/synthetics/monitors',
auth: { username: 'elastic', password: 'jdpAyka8HBiq81dFAIB86Nkp' },
headers: { 'kbn-xsrf': 'true', 'elastic-api-version': '2023-10-31' },
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
});
};
const getHttpMonitor = (isDown?: boolean) => {
return {
type: 'http',
form_monitor_type: 'http',
enabled: true,
alert: { status: { enabled: true }, tls: { enabled: true } },
schedule: { number: '3', unit: 'm' },
'service.name': '',
config_id: '',
tags: [],
timeout: '16',
name: 'Monitor at ' + moment().format('LTS'),
locations: [
{ id: 'us_central_staging', label: 'US Central Staging', isServiceManaged: true },
{ id: 'us_central', label: 'North America - US Central', isServiceManaged: true },
{ id: 'us_central_qa', label: 'US Central QA', isServiceManaged: true },
],
namespace: 'default',
origin: 'ui',
journey_id: '',
hash: '',
id: '',
params: '',
max_attempts: 2,
revision: 1,
__ui: { is_tls_enabled: false },
urls: 'https://www.google.com',
max_redirects: '0',
'url.port': null,
password: '',
proxy_url: '',
proxy_headers: {},
'check.response.body.negative': [],
'check.response.body.positive': isDown ? ["i don't exist"] : [],
'check.response.json': [],
'response.include_body': 'on_error',
'check.response.headers': {},
'response.include_headers': true,
'check.response.status': [],
'check.request.body': { type: 'text', value: '' },
'check.request.headers': {},
'check.request.method': 'GET',
username: '',
mode: 'any',
'response.include_body_max_bytes': '1024',
ipv4: true,
ipv6: true,
'ssl.certificate_authorities': '',
'ssl.certificate': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.verification_mode': 'full',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
};
};

View file

@ -13,6 +13,9 @@ import {
} from '../../common/runtime_types/alert_rules/common';
const dateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS';
const monitorName = 'test-monitor';
const monitorId = '12345';
const configId = '56789';
describe('updateState', () => {
let spy: jest.SpyInstance<string, []>;
@ -190,7 +193,6 @@ describe('updateState', () => {
describe('setRecoveredAlertsContext', () => {
const alertUuid = 'alert-id';
const location = 'us_west';
const configId = '12345';
const idWithLocation = `${configId}-${location}`;
const basePath = {
publicBaseUrl: 'https://localhost:5601',
@ -210,10 +212,19 @@ describe('setRecoveredAlertsContext', () => {
},
},
monitor: {
name: 'test-monitor',
name: monitorName,
},
observer: {
geo: {
name: location,
},
},
} as StaleDownConfig['ping'],
timestamp: new Date().toISOString(),
checks: {
downWithinXChecks: 1,
down: 0,
},
},
};
@ -227,20 +238,23 @@ describe('setRecoveredAlertsContext', () => {
alert: {
getUuid: () => alertUuid,
getId: () => idWithLocation,
getState: () => ({}),
getState: () => ({
downThreshold: 1,
}),
setContext: jest.fn(),
},
hit: {
'kibana.alert.instance.id': idWithLocation,
'location.id': location,
configId,
downThreshold: 1,
},
},
]),
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: Record<string, StaleDownConfig> = {
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {
[idWithLocation]: {
configId,
monitorQueryId: 'stale-config',
@ -252,11 +266,20 @@ describe('setRecoveredAlertsContext', () => {
id: '123456',
},
monitor: {
name: 'test-monitor',
name: monitorName,
},
observer: {
geo: {
name: location,
},
},
} as StaleDownConfig['ping'],
timestamp: new Date().toISOString(),
isDeleted: true,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
};
setRecoveredAlertsContext({
@ -267,26 +290,30 @@ describe('setRecoveredAlertsContext', () => {
upConfigs: {},
dateFormat,
tz: 'UTC',
groupByLocation: true,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
context: {
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
configId: '12345',
configId,
linkMessage: '',
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName: 'test-monitor',
recoveryReason: 'the monitor has been deleted',
'kibana.alert.reason': 'the monitor has been deleted',
monitorName,
recoveryReason: 'has been deleted',
recoveryStatus: 'has been deleted',
monitorUrl: '(unavailable)',
monitorUrlLabel: 'URL',
reason:
'Monitor "test-monitor" from Unnamed-location is recovered. Checked at February 25, 2023 7:00 PM.',
'Monitor "test-monitor" from us_west is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
stateId: '123456',
status: 'recovered',
locationId: location,
locationNames: location,
locationName: location,
idWithLocation,
timestamp: '2023-02-26T00:00:00.000Z',
downThreshold: 1,
},
});
});
@ -301,7 +328,9 @@ describe('setRecoveredAlertsContext', () => {
alert: {
getUuid: () => alertUuid,
getId: () => idWithLocation,
getState: () => ({}),
getState: () => ({
downThreshold: 1,
}),
setContext: jest.fn(),
},
hit: {
@ -314,7 +343,7 @@ describe('setRecoveredAlertsContext', () => {
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: Record<string, StaleDownConfig> = {
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {
[idWithLocation]: {
configId,
monitorQueryId: 'stale-config',
@ -328,9 +357,18 @@ describe('setRecoveredAlertsContext', () => {
monitor: {
name: 'test-monitor',
},
observer: {
geo: {
name: 'us_west',
},
},
} as StaleDownConfig['ping'],
timestamp: new Date().toISOString(),
isLocationRemoved: true,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
};
setRecoveredAlertsContext({
@ -341,26 +379,30 @@ describe('setRecoveredAlertsContext', () => {
upConfigs: {},
dateFormat,
tz: 'UTC',
groupByLocation: true,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
context: {
configId: '12345',
configId,
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
monitorUrl: '(unavailable)',
reason:
'Monitor "test-monitor" from Unnamed-location is recovered. Checked at February 25, 2023 7:00 PM.',
idWithLocation,
linkMessage: '',
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName: 'test-monitor',
monitorName,
recoveryReason: 'this location has been removed from the monitor',
'kibana.alert.reason': 'this location has been removed from the monitor',
recoveryStatus: 'has recovered',
stateId: '123456',
status: 'recovered',
monitorUrlLabel: 'URL',
idWithLocation,
timestamp: '2023-02-26T00:00:00.000Z',
locationName: location,
locationNames: location,
reason:
'Monitor "test-monitor" from us_west is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
locationId: location,
downThreshold: 1,
},
});
});
@ -375,7 +417,9 @@ describe('setRecoveredAlertsContext', () => {
alert: {
getId: () => idWithLocation,
getUuid: () => alertUuid,
getState: () => ({}),
getState: () => ({
downThreshold: 1,
}),
setContext: jest.fn(),
},
hit: {
@ -388,7 +432,7 @@ describe('setRecoveredAlertsContext', () => {
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: Record<string, StaleDownConfig> = {
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {
[idWithLocation]: {
configId,
monitorQueryId: 'stale-config',
@ -405,6 +449,10 @@ describe('setRecoveredAlertsContext', () => {
} as StaleDownConfig['ping'],
timestamp: new Date().toISOString(),
isLocationRemoved: true,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
};
setRecoveredAlertsContext({
@ -415,6 +463,7 @@ describe('setRecoveredAlertsContext', () => {
upConfigs,
dateFormat,
tz: 'UTC',
groupByLocation: true,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
@ -422,22 +471,250 @@ describe('setRecoveredAlertsContext', () => {
configId,
idWithLocation,
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName: 'test-monitor',
monitorName,
status: 'up',
recoveryReason:
'the monitor is now up again. It ran successfully at Feb 26, 2023 @ 00:00:00.000',
'kibana.alert.reason':
'the monitor is now up again. It ran successfully at Feb 26, 2023 @ 00:00:00.000',
recoveryStatus: 'is now up',
locationId: location,
locationNames: location,
locationName: location,
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
linkMessage:
'- Link: https://localhost:5601/app/synthetics/monitor/12345/errors/123456?locationId=us_west',
linkMessage: `- Link: https://localhost:5601/app/synthetics/monitor/${configId}/errors/123456?locationId=us_west`,
monitorUrl: '(unavailable)',
monitorUrlLabel: 'URL',
reason:
'Monitor "test-monitor" from Unnamed-location is recovered. Checked at February 25, 2023 7:00 PM.',
stateId: null,
'Monitor "test-monitor" from us_west is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
timestamp: '2023-02-26T00:00:00.000Z',
downThreshold: 1,
stateId: '123456',
},
});
});
it('sets the correct default recovery summary', () => {
const alertsClientMock = {
report: jest.fn(),
getAlertLimitValue: jest.fn().mockReturnValue(10),
setAlertLimitReached: jest.fn(),
getRecoveredAlerts: jest.fn().mockReturnValue([
{
alert: {
getId: () => idWithLocation,
getUuid: () => alertUuid,
getState: () => ({
downThreshold: 1,
}),
setContext: jest.fn(),
},
hit: {
'kibana.alert.instance.id': idWithLocation,
'location.id': location,
'monitor.name': monitorName,
'monitor.id': monitorId,
'@timestamp': new Date().toISOString(),
'agent.name': 'test-host',
'observer.geo.name': 'Unnamed-location',
'observer.name.keyword': 'Unnamed-location-id',
'monitor.type': 'HTTP',
'error.message': 'test-error-message',
configId,
},
},
]),
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {};
setRecoveredAlertsContext({
alertsClient: alertsClientMock,
basePath,
spaceId: 'default',
staleDownConfigs,
upConfigs: {},
dateFormat,
tz: 'UTC',
groupByLocation: true,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
context: {
configId,
idWithLocation,
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName,
monitorId,
status: 'recovered',
recoveryReason: 'the alert condition is no longer met',
recoveryStatus: 'has recovered',
locationId: location,
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
linkMessage: '',
monitorUrl: '(unavailable)',
monitorUrlLabel: 'URL',
reason:
'Monitor "test-monitor" from Unnamed-location is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
timestamp: '2023-02-26T00:00:00.000Z',
downThreshold: 1,
locationNames: 'Unnamed-location',
locationName: 'Unnamed-location',
lastErrorMessage: 'test-error-message',
monitorType: 'HTTP',
hostName: 'test-host',
},
});
});
it('sets the recovery summary for recovered custom alerts', () => {
const alertsClientMock = {
report: jest.fn(),
getAlertLimitValue: jest.fn().mockReturnValue(10),
setAlertLimitReached: jest.fn(),
getRecoveredAlerts: jest.fn().mockReturnValue([
{
alert: {
getId: () => idWithLocation,
getUuid: () => alertUuid,
getState: () => ({
downThreshold: 1,
configId,
}),
setContext: jest.fn(),
},
hit: {
'kibana.alert.instance.id': idWithLocation,
'location.id': ['us_central', 'us_west'],
'monitor.name': monitorName,
'monitor.id': monitorId,
'monitor.type': 'HTTP',
'monitor.state.id': '123456',
'@timestamp': new Date().toISOString(),
'observer.geo.name': ['us-central', 'us-east'],
'error.message': 'test-error-message',
'url.full': 'http://test_url.com',
configId,
'agent.name': 'test-agent',
},
},
]),
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {};
setRecoveredAlertsContext({
alertsClient: alertsClientMock,
basePath,
spaceId: 'default',
staleDownConfigs,
upConfigs: {},
dateFormat,
tz: 'UTC',
groupByLocation: true,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
context: {
configId,
idWithLocation,
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName,
monitorId,
status: 'recovered',
recoveryReason: 'the alert condition is no longer met',
recoveryStatus: 'has recovered',
locationId: 'us_central and us_west',
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
linkMessage:
'- Link: https://localhost:5601/app/synthetics/monitor/56789/errors/123456?locationId=us_central',
monitorUrl: 'http://test_url.com',
hostName: 'test-agent',
monitorUrlLabel: 'URL',
reason:
'Monitor "test-monitor" from us-central and us-east is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
stateId: '123456',
timestamp: '2023-02-26T00:00:00.000Z',
downThreshold: 1,
locationNames: 'us-central and us-east',
locationName: 'us-central and us-east',
monitorType: 'HTTP',
lastErrorMessage: 'test-error-message',
},
});
});
it('handles ungrouped recoveries', () => {
const alertsClientMock = {
report: jest.fn(),
getAlertLimitValue: jest.fn().mockReturnValue(10),
setAlertLimitReached: jest.fn(),
getRecoveredAlerts: jest.fn().mockReturnValue([
{
alert: {
getId: () => idWithLocation,
getUuid: () => alertUuid,
getState: () => ({
downThreshold: 1,
configId,
}),
setContext: jest.fn(),
},
hit: {
'kibana.alert.instance.id': idWithLocation,
'location.id': location,
'monitor.name': monitorName,
'monitor.type': 'HTTP',
'monitor.id': monitorId,
'agent.name': 'test-agent',
'@timestamp': new Date().toISOString(),
'observer.geo.name': ['us-central', 'us-east'],
'error.message': 'test-error-message',
'url.full': 'http://test_url.com',
'monitor.state.id': '123456',
configId,
},
},
]),
setAlertData: jest.fn(),
isTrackedAlert: jest.fn(),
};
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {};
setRecoveredAlertsContext({
alertsClient: alertsClientMock,
basePath,
spaceId: 'default',
staleDownConfigs,
upConfigs: {},
dateFormat,
tz: 'UTC',
groupByLocation: false,
});
expect(alertsClientMock.setAlertData).toBeCalledWith({
id: idWithLocation,
context: {
configId,
idWithLocation,
alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id',
monitorName,
monitorId,
status: 'recovered',
recoveryReason: 'the alert condition is no longer met',
recoveryStatus: 'has recovered',
locationId: location,
checkedAt: 'Feb 26, 2023 @ 00:00:00.000',
linkMessage:
'- Link: https://localhost:5601/app/synthetics/monitor/56789/errors/123456?locationId=us_west',
monitorUrl: 'http://test_url.com',
hostName: 'test-agent',
monitorUrlLabel: 'URL',
reason:
'Monitor "test-monitor" from us-central and us-east is recovered. Alert when 1 out of the last 1 checks are down from at least 1 location.',
stateId: '123456',
timestamp: '2023-02-26T00:00:00.000Z',
downThreshold: 1,
locationNames: 'us-central and us-east',
locationName: 'us-central and us-east',
monitorType: 'HTTP',
lastErrorMessage: 'test-error-message',
},
});
});

View file

@ -4,24 +4,26 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment, { Moment } from 'moment';
import moment from 'moment';
import { isRight } from 'fp-ts/lib/Either';
import Mustache from 'mustache';
import { IBasePath } from '@kbn/core/server';
import {
IRuleTypeAlerts,
ActionGroupIdsOf,
AlertInstanceContext as AlertContext,
AlertInstanceState as AlertState,
IRuleTypeAlerts,
} from '@kbn/alerting-plugin/server';
import { getAlertDetailsUrl } from '@kbn/observability-plugin/common';
import { addSpaceIdToPath } from '@kbn/spaces-plugin/common';
import { i18n } from '@kbn/i18n';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { legacyExperimentalFieldMap, ObservabilityUptimeAlert } from '@kbn/alerts-as-data-utils';
import { PublicAlertsClient } from '@kbn/alerting-plugin/server/alerts_client/types';
import { ALERT_REASON } from '@kbn/rule-data-utils';
import { legacyExperimentalFieldMap } from '@kbn/alerts-as-data-utils';
import {
PublicAlertsClient,
RecoveredAlertData,
} from '@kbn/alerting-plugin/server/alerts_client/types';
import { StatusRuleParams, TimeWindow } from '../../common/rules/status_rule';
import { syntheticsRuleFieldMap } from '../../common/rules/synthetics_rule_field_map';
import { combineFiltersAndUserSearch, stringifyKueries } from '../../common/lib';
import {
@ -29,17 +31,18 @@ import {
SYNTHETICS_RULE_TYPES_ALERT_CONTEXT,
} from '../../common/constants/synthetics_alerts';
import { getUptimeIndexPattern, IndexPatternTitleAndFields } from '../queries/get_index_pattern';
import { StatusCheckFilters } from '../../common/runtime_types';
import { OverviewPing, StatusCheckFilters } from '../../common/runtime_types';
import { SyntheticsEsClient } from '../lib';
import { getMonitorSummary } from './status_rule/message_utils';
import {
AlertOverviewStatus,
SyntheticsCommonState,
SyntheticsCommonStateCodec,
SyntheticsMonitorStatusAlertState,
} from '../../common/runtime_types/alert_rules/common';
import { getSyntheticsErrorRouteFromMonitorId } from '../../common/utils/get_synthetics_monitor_url';
import { ALERT_DETAILS_URL, RECOVERY_REASON } from './action_variables';
import type { MonitorSummaryStatusRule } from './status_rule/types';
import type { MonitorStatusAlertDocument, MonitorSummaryStatusRule } from './status_rule/types';
export const updateState = (
state: SyntheticsCommonState,
@ -124,70 +127,58 @@ export const getRelativeViewInAppUrl = ({
stateId: string;
locationId: string;
}) => {
const relativeViewInAppUrl = getSyntheticsErrorRouteFromMonitorId({
return getSyntheticsErrorRouteFromMonitorId({
configId,
stateId,
locationId,
});
return relativeViewInAppUrl;
};
export const getErrorDuration = (startedAt: Moment, endsAt: Moment) => {
const diffInDays = endsAt.diff(startedAt, 'days');
if (diffInDays > 1) {
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.days', {
defaultMessage: '{value} days',
values: { value: diffInDays },
});
}
const diffInHours = endsAt.diff(startedAt, 'hours');
if (diffInHours > 1) {
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.hours', {
defaultMessage: '{value} hours',
values: { value: diffInHours },
});
}
const diffInMinutes = endsAt.diff(startedAt, 'minutes');
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.mins', {
defaultMessage: '{value} mins',
values: { value: diffInMinutes },
});
};
export const setRecoveredAlertsContext = ({
alertsClient,
basePath,
spaceId,
staleDownConfigs,
staleDownConfigs = {},
upConfigs,
dateFormat,
tz,
params,
groupByLocation,
}: {
alertsClient: PublicAlertsClient<
ObservabilityUptimeAlert,
AlertState,
MonitorStatusAlertDocument,
SyntheticsMonitorStatusAlertState,
AlertContext,
ActionGroupIdsOf<MonitorStatusActionGroup>
>;
basePath?: IBasePath;
spaceId?: string;
params?: StatusRuleParams;
staleDownConfigs: AlertOverviewStatus['staleDownConfigs'];
upConfigs: AlertOverviewStatus['upConfigs'];
dateFormat: string;
tz: string;
groupByLocation: boolean;
}) => {
const recoveredAlerts = alertsClient.getRecoveredAlerts() ?? [];
for (const recoveredAlert of recoveredAlerts) {
const recoveredAlertId = recoveredAlert.alert.getId();
const alertUuid = recoveredAlert.alert.getUuid();
const state = recoveredAlert.alert.getState();
const alertHit = recoveredAlert.hit;
const locationId = alertHit?.['location.id'];
const alertState = recoveredAlert.alert.getState();
const configId = alertHit?.configId;
const locationIds = alertHit?.['location.id'] ? [alertHit?.['location.id']].flat() : [];
const locationName = alertHit?.['observer.geo.name']
? [alertHit?.['observer.geo.name']].flat()
: [];
let syntheticsStateId = alertHit?.['monitor.state.id'];
let recoveryReason = '';
let recoveryReason = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.defaultRecovery.reason',
{
defaultMessage: `the alert condition is no longer met`,
}
);
let recoveryStatus = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.defaultRecovery.status',
{
@ -195,107 +186,94 @@ export const setRecoveredAlertsContext = ({
}
);
let isUp = false;
let linkMessage = '';
let monitorSummary: MonitorSummaryStatusRule | null = null;
let lastErrorMessage;
let linkMessage = getDefaultLinkMessage({
basePath,
spaceId,
syntheticsStateId,
configId,
locationId: locationIds[0],
});
let monitorSummary: MonitorSummaryStatusRule | undefined = getDefaultRecoveredSummary({
recoveredAlert,
tz,
dateFormat,
params,
});
let lastErrorMessage = alertHit?.['error.message'];
if (recoveredAlertId && locationId && staleDownConfigs[recoveredAlertId]) {
const downConfig = staleDownConfigs[recoveredAlertId];
const { ping } = downConfig;
monitorSummary = getMonitorSummary(
ping,
RECOVERED_LABEL,
locationId,
downConfig.configId,
dateFormat,
tz
);
lastErrorMessage = monitorSummary.lastErrorMessage;
if (downConfig.isDeleted) {
recoveryStatus = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.deleteMonitor.status',
{
defaultMessage: `has been deleted`,
}
);
recoveryReason = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.deleteMonitor.reason',
{
defaultMessage: `the monitor has been deleted`,
}
);
} else if (downConfig.isLocationRemoved) {
recoveryStatus = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.removedLocation.status',
{
defaultMessage: `has recovered`,
}
);
recoveryReason = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.removedLocation.reason',
{
defaultMessage: `this location has been removed from the monitor`,
}
);
}
if (!groupByLocation && monitorSummary) {
const formattedLocationNames = locationName.join(` ${AND_LABEL} `);
const formattedLocationIds = locationIds.join(` ${AND_LABEL} `);
monitorSummary.locationNames = formattedLocationNames;
monitorSummary.locationName = formattedLocationNames;
monitorSummary.locationId = formattedLocationIds;
}
if (configId && recoveredAlertId && locationId && upConfigs[recoveredAlertId]) {
// pull the last error from state, since it is not available on the up ping
lastErrorMessage = alertHit?.['error.message'];
const upConfig = upConfigs[recoveredAlertId];
isUp = Boolean(upConfig) || false;
const ping = upConfig.ping;
monitorSummary = getMonitorSummary(
ping,
RECOVERED_LABEL,
locationId,
configId,
if (recoveredAlertId && locationIds && staleDownConfigs[recoveredAlertId]) {
const summary = getDeletedMonitorOrLocationSummary({
staleDownConfigs,
recoveredAlertId,
locationIds,
dateFormat,
tz
);
// When alert is flapping, the stateId is not available on ping.state.ends.id, use state instead
const stateId = ping.state?.ends?.id || state.stateId;
const upTimestamp = ping['@timestamp'];
const checkedAt = moment(upTimestamp).tz(tz).format(dateFormat);
recoveryStatus = i18n.translate('xpack.synthetics.alerts.monitorStatus.upCheck.status', {
defaultMessage: `is now up`,
tz,
params,
});
recoveryReason = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.upCheck.reasonWithoutDuration',
{
defaultMessage: `the monitor is now up again. It ran successfully at {checkedAt}`,
values: {
checkedAt,
},
}
);
if (summary) {
monitorSummary = {
...monitorSummary,
...summary.monitorSummary,
};
recoveryStatus = summary.recoveryStatus;
recoveryReason = summary.recoveryReason;
lastErrorMessage = summary.lastErrorMessage;
syntheticsStateId = summary.stateId ? summary.stateId : syntheticsStateId;
}
// Cannot display link message for deleted monitors or deleted locations
linkMessage = '';
}
if (basePath && spaceId && stateId) {
const relativeViewInAppUrl = getRelativeViewInAppUrl({
configId,
locationId,
stateId,
});
linkMessage = getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl);
if (configId && recoveredAlertId && locationIds && upConfigs[recoveredAlertId]) {
const summary = getUpMonitorRecoverySummary({
upConfigs,
recoveredAlertId,
alertHit,
locationIds,
configId,
basePath,
spaceId,
dateFormat,
tz,
params,
});
if (summary) {
monitorSummary = {
...monitorSummary,
...summary.monitorSummary,
};
recoveryStatus = summary.recoveryStatus;
recoveryReason = summary.recoveryReason;
isUp = summary.isUp;
lastErrorMessage = summary.lastErrorMessage;
linkMessage = summary.linkMessage ? summary.linkMessage : linkMessage;
syntheticsStateId = summary.stateId ? summary.stateId : syntheticsStateId;
}
}
const context = {
...state,
...alertState,
...(monitorSummary ? monitorSummary : {}),
locationId,
locationId: locationIds.join(` ${AND_LABEL} `),
idWithLocation: recoveredAlertId,
lastErrorMessage,
recoveryStatus,
linkMessage,
stateId: syntheticsStateId,
...(isUp ? { status: 'up' } : {}),
...(recoveryReason ? { [RECOVERY_REASON]: recoveryReason } : {}),
...(recoveryReason ? { [ALERT_REASON]: recoveryReason } : {}),
...(recoveryReason
? {
[RECOVERY_REASON]: recoveryReason,
}
: {}),
...(basePath && spaceId && alertUuid
? { [ALERT_DETAILS_URL]: getAlertDetailsUrl(basePath, spaceId, alertUuid) }
: {}),
@ -304,6 +282,220 @@ export const setRecoveredAlertsContext = ({
}
};
export const getDefaultLinkMessage = ({
basePath,
spaceId,
syntheticsStateId,
configId,
locationId,
}: {
basePath?: IBasePath;
spaceId?: string;
syntheticsStateId?: string;
configId?: string;
locationId?: string;
}) => {
if (basePath && spaceId && syntheticsStateId && configId && locationId) {
const relativeViewInAppUrl = getRelativeViewInAppUrl({
configId,
locationId,
stateId: syntheticsStateId,
});
return getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl);
} else {
return '';
}
};
export const getDefaultRecoveredSummary = ({
recoveredAlert,
tz,
dateFormat,
params,
}: {
recoveredAlert: RecoveredAlertData<
MonitorStatusAlertDocument,
AlertState,
AlertContext,
ActionGroupIdsOf<MonitorStatusActionGroup>
>;
tz: string;
dateFormat: string;
params?: StatusRuleParams;
}) => {
if (!recoveredAlert.hit) return; // TODO: handle this case
const hit = recoveredAlert.hit;
const locationId = hit['location.id'];
const configId = hit.configId;
return getMonitorSummary({
monitorInfo: {
monitor: {
id: hit['monitor.id'],
name: hit['monitor.name'],
type: hit['monitor.type'],
},
config_id: configId,
observer: {
geo: {
name: hit['observer.geo.name'] || hit['location.name'],
},
name: locationId,
},
agent: {
name: hit['agent.name'] || '',
},
'@timestamp': String(hit['@timestamp']),
...(hit['error.message'] ? { error: { message: hit['error.message'] } } : {}),
...(hit['url.full'] ? { url: { full: hit['url.full'] } } : {}),
} as unknown as OverviewPing,
statusMessage: RECOVERED_LABEL,
locationId,
configId,
dateFormat,
tz,
params,
});
};
export const getDeletedMonitorOrLocationSummary = ({
staleDownConfigs,
recoveredAlertId,
locationIds,
dateFormat,
tz,
params,
}: {
staleDownConfigs: AlertOverviewStatus['staleDownConfigs'];
recoveredAlertId: string;
locationIds: string[];
dateFormat: string;
tz: string;
params?: StatusRuleParams;
}) => {
const downConfig = staleDownConfigs[recoveredAlertId];
const { ping } = downConfig;
const monitorSummary = getMonitorSummary({
monitorInfo: ping,
statusMessage: RECOVERED_LABEL,
locationId: locationIds,
configId: downConfig.configId,
dateFormat,
tz,
params,
});
const lastErrorMessage = monitorSummary.lastErrorMessage;
if (downConfig.isDeleted) {
return {
lastErrorMessage,
monitorSummary,
stateId: ping.state?.id,
recoveryStatus: i18n.translate('xpack.synthetics.alerts.monitorStatus.deleteMonitor.status', {
defaultMessage: `has been deleted`,
}),
recoveryReason: i18n.translate('xpack.synthetics.alerts.monitorStatus.deleteMonitor.status', {
defaultMessage: `has been deleted`,
}),
};
} else if (downConfig.isLocationRemoved) {
return {
monitorSummary,
lastErrorMessage,
stateId: ping.state?.id,
recoveryStatus: i18n.translate(
'xpack.synthetics.alerts.monitorStatus.removedLocation.status',
{
defaultMessage: `has recovered`,
}
),
recoveryReason: i18n.translate(
'xpack.synthetics.alerts.monitorStatus.removedLocation.reason',
{
defaultMessage: `this location has been removed from the monitor`,
}
),
};
}
};
export const getUpMonitorRecoverySummary = ({
upConfigs,
recoveredAlertId,
alertHit,
locationIds,
configId,
basePath,
spaceId,
dateFormat,
tz,
params,
}: {
upConfigs: AlertOverviewStatus['upConfigs'];
recoveredAlertId: string;
alertHit: any;
locationIds: string[];
configId: string;
basePath?: IBasePath;
spaceId?: string;
dateFormat: string;
tz: string;
params?: StatusRuleParams;
}) => {
// pull the last error from state, since it is not available on the up ping
const lastErrorMessage = alertHit?.['error.message'];
let linkMessage = '';
const upConfig = upConfigs[recoveredAlertId];
const isUp = Boolean(upConfig) || false;
const ping = upConfig.ping;
const monitorSummary = getMonitorSummary({
monitorInfo: ping,
statusMessage: RECOVERED_LABEL,
locationId: locationIds,
configId,
dateFormat,
tz,
params,
});
// When alert is flapping, the stateId is not available on ping.state.ends.id, use state instead
const stateId = ping.state?.ends?.id;
const upTimestamp = ping['@timestamp'];
const checkedAt = moment(upTimestamp).tz(tz).format(dateFormat);
const recoveryStatus = i18n.translate('xpack.synthetics.alerts.monitorStatus.upCheck.status', {
defaultMessage: `is now up`,
});
const recoveryReason = i18n.translate(
'xpack.synthetics.alerts.monitorStatus.upCheck.reasonWithoutDuration',
{
defaultMessage: `the monitor is now up again. It ran successfully at {checkedAt}`,
values: {
checkedAt,
},
}
);
if (basePath && spaceId && stateId) {
const relativeViewInAppUrl = getRelativeViewInAppUrl({
configId,
locationId: locationIds[0],
stateId,
});
linkMessage = getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl);
}
return {
monitorSummary,
lastErrorMessage,
recoveryStatus,
recoveryReason,
isUp,
linkMessage,
stateId,
};
};
export const RECOVERED_LABEL = i18n.translate('xpack.synthetics.monitorStatus.recoveredLabel', {
defaultMessage: 'recovered',
});
@ -355,9 +547,39 @@ export const syntheticsRuleTypeFieldMap = {
...legacyExperimentalFieldMap,
};
export const SyntheticsRuleTypeAlertDefinition: IRuleTypeAlerts<ObservabilityUptimeAlert> = {
export const SyntheticsRuleTypeAlertDefinition: IRuleTypeAlerts<MonitorStatusAlertDocument> = {
context: SYNTHETICS_RULE_TYPES_ALERT_CONTEXT,
mappings: { fieldMap: syntheticsRuleTypeFieldMap },
useLegacyAlerts: true,
shouldWrite: true,
};
export function getTimeUnitLabel(timeWindow: TimeWindow) {
const { size: timeValue = 1, unit: timeUnit } = timeWindow;
switch (timeUnit) {
case 's':
return i18n.translate('xpack.synthetics.timeUnits.secondLabel', {
defaultMessage: '{timeValue, plural, one {second} other {seconds}}',
values: { timeValue },
});
case 'm':
return i18n.translate('xpack.synthetics.timeUnits.minuteLabel', {
defaultMessage: '{timeValue, plural, one {minute} other {minutes}}',
values: { timeValue },
});
case 'h':
return i18n.translate('xpack.synthetics.timeUnits.hourLabel', {
defaultMessage: '{timeValue, plural, one {hour} other {hours}}',
values: { timeValue },
});
case 'd':
return i18n.translate('xpack.synthetics.timeUnits.dayLabel', {
defaultMessage: '{timeValue, plural, one {day} other {days}}',
values: { timeValue },
});
}
}
export const AND_LABEL = i18n.translate('xpack.synthetics.alerts.monitorStatus.andLabel', {
defaultMessage: 'and',
});

View file

@ -8,6 +8,9 @@
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { ALERT_REASON } from '@kbn/rule-data-utils';
import { AlertStatusMetaData } from '../../../common/runtime_types/alert_rules/common';
import { getConditionType, StatusRuleParams } from '../../../common/rules/status_rule';
import { AND_LABEL, getTimeUnitLabel } from '../common';
import { ALERT_REASON_MSG } from '../action_variables';
import { MonitorSummaryStatusRule } from './types';
import {
@ -15,6 +18,7 @@ import {
MONITOR_TYPE,
MONITOR_NAME,
OBSERVER_GEO_NAME,
OBSERVER_NAME,
URL_FULL,
ERROR_MESSAGE,
AGENT_NAME,
@ -23,33 +27,64 @@ import {
import { OverviewPing } from '../../../common/runtime_types';
import { UNNAMED_LOCATION } from '../../../common/constants';
export const getMonitorAlertDocument = (monitorSummary: MonitorSummaryStatusRule) => ({
export const getMonitorAlertDocument = (
monitorSummary: MonitorSummaryStatusRule,
locationNames: string[],
locationIds: string[],
useLatestChecks: boolean
) => ({
[MONITOR_ID]: monitorSummary.monitorId,
[MONITOR_TYPE]: monitorSummary.monitorType,
[MONITOR_NAME]: monitorSummary.monitorName,
[URL_FULL]: monitorSummary.monitorUrl,
[OBSERVER_GEO_NAME]: monitorSummary.locationName,
[OBSERVER_GEO_NAME]: locationNames,
[OBSERVER_NAME]: locationIds,
[ERROR_MESSAGE]: monitorSummary.lastErrorMessage,
[AGENT_NAME]: monitorSummary.hostName,
[ALERT_REASON]: monitorSummary.reason,
[STATE_ID]: monitorSummary.stateId,
'location.id': monitorSummary.locationId,
'location.name': monitorSummary.locationName,
'location.id': locationIds,
'location.name': locationNames,
configId: monitorSummary.configId,
'kibana.alert.evaluation.threshold': monitorSummary.downThreshold,
'kibana.alert.evaluation.value':
(useLatestChecks ? monitorSummary.checks?.downWithinXChecks : monitorSummary.checks?.down) ?? 1,
'monitor.tags': monitorSummary.monitorTags ?? [],
});
export const getMonitorSummary = (
monitorInfo: OverviewPing,
statusMessage: string,
locationId: string,
configId: string,
dateFormat: string,
tz: string
): MonitorSummaryStatusRule => {
const monitorName = monitorInfo.monitor?.name ?? monitorInfo.monitor?.id;
const observerLocation = monitorInfo.observer?.geo?.name ?? UNNAMED_LOCATION;
const checkedAt = moment(monitorInfo['@timestamp']).tz(tz).format(dateFormat);
export interface MonitorSummaryData {
monitorInfo: OverviewPing;
statusMessage: string;
locationId: string[];
configId: string;
dateFormat: string;
tz: string;
checks?: {
downWithinXChecks: number;
down: number;
};
params?: StatusRuleParams;
}
export const getMonitorSummary = ({
monitorInfo,
locationId,
configId,
tz,
dateFormat,
statusMessage,
checks,
params,
}: MonitorSummaryData): MonitorSummaryStatusRule => {
const { downThreshold } = getConditionType(params?.condition);
const monitorName = monitorInfo?.monitor?.name ?? monitorInfo?.monitor?.id;
const locationName = monitorInfo?.observer?.geo?.name ?? UNNAMED_LOCATION;
const formattedLocationName = Array.isArray(locationName)
? locationName.join(` ${AND_LABEL} `)
: locationName;
const checkedAt = moment(monitorInfo?.['@timestamp'])
.tz(tz || 'UTC')
.format(dateFormat);
const typeToLabelMap: Record<string, string> = {
http: 'HTTP',
tcp: 'TCP',
@ -65,11 +100,11 @@ export const getMonitorSummary = (
browser: 'URL',
};
const monitorType = monitorInfo.monitor?.type;
const stateId = monitorInfo.state?.id || null;
const stateId = monitorInfo.state?.id;
return {
checkedAt,
locationId,
locationId: locationId?.join?.(` ${AND_LABEL} `) ?? '',
configId,
monitorUrl: monitorInfo.url?.full || UNAVAILABLE_LABEL,
monitorUrlLabel: typeToUrlLabelMap[monitorType] || 'URL',
@ -77,40 +112,162 @@ export const getMonitorSummary = (
monitorName,
monitorType: typeToLabelMap[monitorInfo.monitor?.type] || monitorInfo.monitor?.type,
lastErrorMessage: monitorInfo.error?.message!,
locationName: monitorInfo.observer?.geo?.name!,
locationName: formattedLocationName,
locationNames: formattedLocationName,
hostName: monitorInfo.agent?.name!,
status: statusMessage,
stateId,
[ALERT_REASON_MSG]: getReasonMessage({
name: monitorName,
location: observerLocation,
location: formattedLocationName,
status: statusMessage,
timestamp: monitorInfo['@timestamp'],
checks,
params,
}),
checks,
downThreshold,
timestamp: monitorInfo['@timestamp'],
monitorTags: monitorInfo.tags,
};
};
export const getUngroupedReasonMessage = ({
statusConfigs,
monitorName,
params,
status = DOWN_LABEL,
}: {
statusConfigs: AlertStatusMetaData[];
monitorName: string;
params: StatusRuleParams;
status?: string;
checks?: {
downWithinXChecks: number;
down: number;
};
}) => {
const { useLatestChecks, numberOfChecks, timeWindow, downThreshold, locationsThreshold } =
getConditionType(params.condition);
return i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.reasonMessage.location.ungrouped.multiple',
{
defaultMessage: `Monitor "{name}" is {status} {locationDetails}. Alert when down {threshold} {threshold, plural, one {time} other {times}} {condition} from at least {locationsThreshold} {locationsThreshold, plural, one {location} other {locations}}.`,
values: {
name: monitorName,
status,
threshold: downThreshold,
locationsThreshold,
condition: useLatestChecks
? i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.reasonMessage.condition.latestChecks',
{
defaultMessage: 'out of the last {numberOfChecks} checks',
values: { numberOfChecks },
}
)
: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.reasonMessage.condition.timeWindow',
{
defaultMessage: 'within the last {time} {unit}',
values: {
time: timeWindow.size,
unit: getTimeUnitLabel(timeWindow),
},
}
),
locationDetails: statusConfigs
.map((c) => {
return i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.reasonMessage.locationDetails',
{
defaultMessage:
'{downCount} {downCount, plural, one {time} other {times}} from {locName}',
values: {
locName: c.ping.observer.geo?.name,
downCount: useLatestChecks ? c.checks?.downWithinXChecks : c.checks?.down,
},
}
);
})
.join(` ${AND_LABEL} `),
},
}
);
};
export const getReasonMessage = ({
name,
status,
location,
timestamp,
checks,
params,
}: {
name: string;
location: string;
status: string;
timestamp: string;
checks?: {
downWithinXChecks: number;
down: number;
};
params?: StatusRuleParams;
}) => {
const checkedAt = moment(timestamp).format('LLL');
return i18n.translate('xpack.synthetics.alertRules.monitorStatus.reasonMessage', {
defaultMessage: `Monitor "{name}" from {location} is {status}. Checked at {checkedAt}.`,
const { useTimeWindow, numberOfChecks, locationsThreshold, downThreshold } = getConditionType(
params?.condition
);
if (useTimeWindow) {
return getReasonMessageForTimeWindow({
name,
location,
status,
params,
});
}
return i18n.translate('xpack.synthetics.alertRules.monitorStatus.reasonMessage.new', {
defaultMessage: `Monitor "{name}" from {location} is {status}. {checksSummary}Alert when {downThreshold} out of the last {numberOfChecks} checks are down from at least {locationsThreshold} {locationsThreshold, plural, one {location} other {locations}}.`,
values: {
name,
status,
location,
checkedAt,
downThreshold,
locationsThreshold,
numberOfChecks,
checksSummary: checks
? i18n.translate('xpack.synthetics.alertRules.monitorStatus.reasonMessage.checksSummary', {
defaultMessage:
'Monitor is down {downChecks} {downChecks, plural, one {time} other {times}} within the last {numberOfChecks} checks. ',
values: {
downChecks: checks.downWithinXChecks,
numberOfChecks,
},
})
: '',
},
});
};
export const getReasonMessageForTimeWindow = ({
name,
location,
status = DOWN_LABEL,
params,
}: {
name: string;
location: string;
status?: string;
params?: StatusRuleParams;
}) => {
const { timeWindow, locationsThreshold, downThreshold } = getConditionType(params?.condition);
return i18n.translate('xpack.synthetics.alertRules.monitorStatus.reasonMessage.timeBased', {
defaultMessage: `Monitor "{name}" from {location} is {status}. Alert when {downThreshold} checks are down within the last {size} {unitLabel} from at least {locationsThreshold} {locationsThreshold, plural, one {location} other {locations}}.`,
values: {
name,
status,
location,
downThreshold,
unitLabel: getTimeUnitLabel(timeWindow),
locationsThreshold,
size: timeWindow.size,
},
});
};

View file

@ -7,25 +7,15 @@
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { isEmpty } from 'lodash';
import { ActionGroupIdsOf } from '@kbn/alerting-plugin/common';
import {
GetViewInAppRelativeUrlFnOpts,
AlertInstanceContext as AlertContext,
RuleExecutorOptions,
AlertsClientError,
} from '@kbn/alerting-plugin/server';
import { getAlertDetailsUrl, observabilityPaths } from '@kbn/observability-plugin/common';
import { ObservabilityUptimeAlert } from '@kbn/alerts-as-data-utils';
import { GetViewInAppRelativeUrlFnOpts, AlertsClientError } from '@kbn/alerting-plugin/server';
import { observabilityPaths } from '@kbn/observability-plugin/common';
import apm from 'elastic-apm-node';
import { AlertOverviewStatus } from '../../../common/runtime_types/alert_rules/common';
import { StatusRuleExecutorOptions } from './types';
import { syntheticsRuleFieldMap } from '../../../common/rules/synthetics_rule_field_map';
import { SyntheticsPluginsSetupDependencies, SyntheticsServerSetup } from '../../types';
import { DOWN_LABEL, getMonitorAlertDocument, getMonitorSummary } from './message_utils';
import {
AlertOverviewStatus,
SyntheticsCommonState,
SyntheticsMonitorStatusAlertState,
} from '../../../common/runtime_types/alert_rules/common';
import { StatusRuleExecutor } from './status_rule_executor';
import { StatusRulePramsSchema, StatusRuleParams } from '../../../common/rules/status_rule';
import { StatusRulePramsSchema } from '../../../common/rules/status_rule';
import {
MONITOR_STATUS,
SYNTHETICS_ALERT_RULE_TYPES,
@ -33,22 +23,12 @@ import {
import {
setRecoveredAlertsContext,
updateState,
getViewInAppUrl,
getRelativeViewInAppUrl,
getFullViewInAppMessage,
SyntheticsRuleTypeAlertDefinition,
} from '../common';
import { ALERT_DETAILS_URL, getActionVariables, VIEW_IN_APP_URL } from '../action_variables';
import { getActionVariables } from '../action_variables';
import { STATUS_RULE_NAME } from '../translations';
import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client';
type MonitorStatusRuleTypeParams = StatusRuleParams;
type MonitorStatusActionGroups = ActionGroupIdsOf<typeof MONITOR_STATUS>;
type MonitorStatusRuleTypeState = SyntheticsCommonState;
type MonitorStatusAlertState = SyntheticsMonitorStatusAlertState;
type MonitorStatusAlertContext = AlertContext;
type MonitorStatusAlert = ObservabilityUptimeAlert;
export const registerSyntheticsStatusCheckRule = (
server: SyntheticsServerSetup,
plugins: SyntheticsPluginsSetupDependencies,
@ -74,94 +54,44 @@ export const registerSyntheticsStatusCheckRule = (
isExportable: true,
minimumLicenseRequired: 'basic',
doesSetRecoveryContext: true,
executor: async (
options: RuleExecutorOptions<
MonitorStatusRuleTypeParams,
MonitorStatusRuleTypeState,
MonitorStatusAlertState,
MonitorStatusAlertContext,
MonitorStatusActionGroups,
MonitorStatusAlert
>
) => {
const { state: ruleState, params, services, spaceId, previousStartedAt, startedAt } = options;
const { alertsClient, savedObjectsClient, scopedClusterClient, uiSettingsClient } = services;
executor: async (options: StatusRuleExecutorOptions) => {
apm.setTransactionName('Synthetics Status Rule Executor');
const { state: ruleState, params, services, spaceId } = options;
const { alertsClient, uiSettingsClient } = services;
if (!alertsClient) {
throw new AlertsClientError();
}
const { basePath } = server;
const dateFormat = await uiSettingsClient.get('dateFormat');
const timezone = await uiSettingsClient.get('dateFormat:tz');
const [dateFormat, timezone] = await Promise.all([
uiSettingsClient.get('dateFormat'),
uiSettingsClient.get('dateFormat:tz'),
]);
const tz = timezone === 'Browser' ? 'UTC' : timezone;
const statusRule = new StatusRuleExecutor(
previousStartedAt,
params,
savedObjectsClient,
scopedClusterClient.asCurrentUser,
server,
syntheticsMonitorClient
);
const groupBy = params?.condition?.groupBy ?? 'locationId';
const groupByLocation = groupBy === 'locationId';
const statusRule = new StatusRuleExecutor(server, syntheticsMonitorClient, options);
const { downConfigs, staleDownConfigs, upConfigs } = await statusRule.getDownChecks(
ruleState.meta?.downConfigs as AlertOverviewStatus['downConfigs']
);
Object.entries(downConfigs).forEach(([idWithLocation, { ping, configId }]) => {
const locationId = ping.observer.name ?? '';
const alertId = idWithLocation;
const monitorSummary = getMonitorSummary(
ping,
DOWN_LABEL,
locationId,
configId,
dateFormat,
tz
);
const { uuid, start } = alertsClient.report({
id: alertId,
actionGroup: MONITOR_STATUS.id,
});
const errorStartedAt = start ?? startedAt.toISOString();
let relativeViewInAppUrl = '';
if (monitorSummary.stateId) {
relativeViewInAppUrl = getRelativeViewInAppUrl({
configId,
stateId: monitorSummary.stateId,
locationId,
});
}
const payload = getMonitorAlertDocument(monitorSummary);
const context = {
...monitorSummary,
idWithLocation,
errorStartedAt,
linkMessage: monitorSummary.stateId
? getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl)
: '',
[VIEW_IN_APP_URL]: getViewInAppUrl(basePath, spaceId, relativeViewInAppUrl),
[ALERT_DETAILS_URL]: getAlertDetailsUrl(basePath, spaceId, uuid),
};
alertsClient.setAlertData({
id: alertId,
payload,
context,
});
statusRule.handleDownMonitorThresholdAlert({
downConfigs,
});
setRecoveredAlertsContext({
alertsClient,
basePath,
spaceId,
staleDownConfigs,
upConfigs,
dateFormat,
tz,
params,
groupByLocation,
staleDownConfigs,
upConfigs,
});
return {

View file

@ -0,0 +1,104 @@
/*
* 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 } from '@kbn/es-query';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { StatusRuleParams } from '../../../../common/rules/status_rule';
import { SyntheticsEsClient } from '../../../lib';
import {
FINAL_SUMMARY_FILTER,
getRangeFilter,
getTimeSpanFilter,
} from '../../../../common/constants/client_defaults';
export async function queryFilterMonitors({
spaceId,
esClient,
ruleParams,
}: {
spaceId: string;
esClient: SyntheticsEsClient;
ruleParams: StatusRuleParams;
}) {
if (!ruleParams.kqlQuery) {
return;
}
const filters = toElasticsearchQuery(fromKueryExpression(ruleParams.kqlQuery));
const { body: result } = await esClient.search({
body: {
size: 0,
query: {
bool: {
filter: [
FINAL_SUMMARY_FILTER,
getRangeFilter({ from: 'now-24h/m', to: 'now/m' }),
getTimeSpanFilter(),
{
term: {
'meta.space_id': spaceId,
},
},
{
bool: {
should: filters,
},
},
...getFilters(ruleParams),
],
},
},
aggs: {
ids: {
terms: {
size: 10000,
field: 'config_id',
},
},
},
},
});
return result.aggregations?.ids.buckets.map((bucket) => bucket.key as string);
}
const getFilters = (ruleParams: StatusRuleParams) => {
const { monitorTypes, locations, tags, projects } = ruleParams;
const filters: QueryDslQueryContainer[] = [];
if (monitorTypes?.length) {
filters.push({
terms: {
'monitor.type': monitorTypes,
},
});
}
if (locations?.length) {
filters.push({
terms: {
'observer.name': locations,
},
});
}
if (tags?.length) {
filters.push({
terms: {
tags,
},
});
}
if (projects?.length) {
filters.push({
terms: {
'monitor.project.id': projects,
},
});
}
return filters;
};

View file

@ -8,15 +8,15 @@
import pMap from 'p-map';
import times from 'lodash/times';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { cloneDeep, intersection } from 'lodash';
import { intersection } from 'lodash';
import { AlertStatusMetaData } from '../../../../common/runtime_types/alert_rules/common';
import {
AlertOverviewStatus,
AlertPendingStatusMetaData,
AlertStatusMetaData,
} from '../../../common/runtime_types/alert_rules/common';
import { createEsParams, SyntheticsEsClient } from '../../lib';
import { OverviewPing } from '../../../common/runtime_types';
import { FINAL_SUMMARY_FILTER } from '../../../common/constants/client_defaults';
FINAL_SUMMARY_FILTER,
getTimespanFilter,
SUMMARY_FILTER,
} from '../../../../common/constants/client_defaults';
import { OverviewPing } from '../../../../common/runtime_types';
import { createEsParams, SyntheticsEsClient } from '../../../lib';
const DEFAULT_MAX_ES_BUCKET_SIZE = 10000;
@ -32,23 +32,35 @@ const fields = [
'state',
'tags',
];
type StatusConfigs = Record<string, AlertStatusMetaData>;
export async function queryMonitorStatusForAlert(
esClient: SyntheticsEsClient,
monitorLocationIds: string[],
range: { from: string; to: string },
monitorQueryIds: string[],
monitorLocationsMap: Record<string, string[]>,
monitorQueryIdToConfigIdMap: Record<string, string>
): Promise<AlertOverviewStatus> {
export interface AlertStatusResponse {
upConfigs: StatusConfigs;
downConfigs: StatusConfigs;
enabledMonitorQueryIds: string[];
}
export async function queryMonitorStatusAlert({
esClient,
monitorLocationIds,
range,
monitorQueryIds,
monitorLocationsMap,
numberOfChecks,
includeRetests = true,
}: {
esClient: SyntheticsEsClient;
monitorLocationIds: string[];
range: { from: string; to: string };
monitorQueryIds: string[];
monitorLocationsMap: Record<string, string[]>;
numberOfChecks: number;
includeRetests?: boolean;
}): Promise<AlertStatusResponse> {
const idSize = Math.trunc(DEFAULT_MAX_ES_BUCKET_SIZE / monitorLocationIds.length || 1);
const pageCount = Math.ceil(monitorQueryIds.length / idSize);
let up = 0;
let down = 0;
const upConfigs: Record<string, AlertStatusMetaData> = {};
const downConfigs: Record<string, AlertStatusMetaData> = {};
const monitorsWithoutData = new Map(Object.entries(cloneDeep(monitorLocationsMap)));
const pendingConfigs: Record<string, AlertPendingStatusMetaData> = {};
const upConfigs: StatusConfigs = {};
const downConfigs: StatusConfigs = {};
await pMap(
times(pageCount),
@ -60,15 +72,8 @@ export async function queryMonitorStatusForAlert(
query: {
bool: {
filter: [
FINAL_SUMMARY_FILTER,
{
range: {
'@timestamp': {
gte: range.from,
lte: range.to,
},
},
},
...(includeRetests ? [SUMMARY_FILTER] : [FINAL_SUMMARY_FILTER]),
getTimespanFilter({ from: range.from, to: range.to }),
{
terms: {
'monitor.id': idsToQuery,
@ -90,9 +95,18 @@ export async function queryMonitorStatusForAlert(
size: monitorLocationIds.length || 100,
},
aggs: {
status: {
downChecks: {
filter: {
range: {
'summary.down': {
gte: '1',
},
},
},
},
totalChecks: {
top_hits: {
size: 1,
size: numberOfChecks,
sort: [
{
'@timestamp': {
@ -121,62 +135,60 @@ export async function queryMonitorStatusForAlert(
});
}
const { body: result } = await esClient.search<OverviewPing, typeof params>(
params,
'getCurrentStatusOverview' + i
);
const { body: result } = await esClient.search<OverviewPing, typeof params>(params);
result.aggregations?.id.buckets.forEach(({ location, key: queryId }) => {
const locationSummaries = location.buckets.map(({ status, key: locationName }) => {
const ping = status.hits.hits[0]._source;
return { location: locationName, ping };
});
const locationSummaries = location.buckets.map(
({ key: locationId, totalChecks, downChecks }) => {
return { locationId, totalChecks, downChecks };
}
);
// discard any locations that are not in the monitorLocationsMap for the given monitor as well as those which are
// in monitorLocationsMap but not in listOfLocations
const monLocations = monitorLocationsMap?.[queryId];
const monQueriedLocations = intersection(monLocations, monitorLocationIds);
monQueriedLocations?.forEach((monLocation) => {
monQueriedLocations?.forEach((monLocationId) => {
const locationSummary = locationSummaries.find(
(summary) => summary.location === monLocation
(summary) => summary.locationId === monLocationId
);
if (locationSummary) {
const { ping } = locationSummary;
const downCount = ping.summary?.down ?? 0;
const upCount = ping.summary?.up ?? 0;
const configId = ping.config_id;
const monitorQueryId = ping.monitor.id;
const { totalChecks, downChecks } = locationSummary;
const latestPing = totalChecks.hits.hits[0]._source;
const downCount = downChecks.doc_count;
const isLatestPingUp = (latestPing.summary?.up ?? 0) > 0;
const configId = latestPing.config_id;
const monitorQueryId = latestPing.monitor.id;
const meta = {
ping,
const meta: AlertStatusMetaData = {
ping: latestPing,
configId,
monitorQueryId,
locationId: monLocation,
timestamp: ping['@timestamp'],
locationId: monLocationId,
timestamp: latestPing['@timestamp'],
checks: {
downWithinXChecks: totalChecks.hits.hits.reduce(
(acc, curr) => acc + ((curr._source.summary.down ?? 0) > 0 ? 1 : 0),
0
),
down: downCount,
},
status: 'up',
};
if (downCount > 0) {
down += 1;
downConfigs[`${configId}-${monLocation}`] = {
downConfigs[`${configId}-${monLocationId}`] = {
...meta,
status: 'down',
};
} else if (upCount > 0) {
up += 1;
upConfigs[`${configId}-${monLocation}`] = {
}
if (isLatestPingUp) {
upConfigs[`${configId}-${monLocationId}`] = {
...meta,
status: 'up',
};
}
const monitorsMissingData = monitorsWithoutData.get(monitorQueryId) || [];
monitorsWithoutData.set(
monitorQueryId,
monitorsMissingData?.filter((loc) => loc !== monLocation)
);
if (!monitorsWithoutData.get(monitorQueryId)?.length) {
monitorsWithoutData.delete(monitorQueryId);
}
}
});
});
@ -184,26 +196,9 @@ export async function queryMonitorStatusForAlert(
{ concurrency: 5 }
);
// identify the remaining monitors without data, to determine pending monitors
for (const [queryId, locs] of monitorsWithoutData) {
locs.forEach((loc) => {
pendingConfigs[`${monitorQueryIdToConfigIdMap[queryId]}-${loc}`] = {
configId: `${monitorQueryIdToConfigIdMap[queryId]}`,
monitorQueryId: queryId,
status: 'unknown',
locationId: loc,
};
});
}
return {
up,
down,
pending: Object.values(pendingConfigs).length,
upConfigs,
downConfigs,
pendingConfigs,
enabledMonitorQueryIds: monitorQueryIds,
staleDownConfigs: {},
};
}

View file

@ -4,10 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { loggerMock } from '@kbn/logging-mocks';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { StatusRuleExecutor } from './status_rule_executor';
import { coreMock } from '@kbn/core/server/mocks';
import { getDoesMonitorMeetLocationThreshold, StatusRuleExecutor } from './status_rule_executor';
import { mockEncryptedSO } from '../../synthetics_service/utils/mocks';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client';
@ -16,8 +16,12 @@ import * as monitorUtils from '../../saved_objects/synthetics_monitor/get_all_mo
import * as locationsUtils from '../../synthetics_service/get_all_locations';
import type { PublicLocation } from '../../../common/runtime_types';
import { SyntheticsServerSetup } from '../../types';
import { AlertStatusMetaData } from '../../../common/runtime_types/alert_rules/common';
describe('StatusRuleExecutor', () => {
// @ts-ignore
Date.now = jest.fn(() => new Date('2024-05-13T12:33:37.000Z'));
const mockEsClient = elasticsearchClientMock.createElasticsearchClient();
const logger = loggerMock.create();
const soClient = savedObjectsClientMock.create();
@ -59,166 +63,611 @@ describe('StatusRuleExecutor', () => {
const monitorClient = new SyntheticsMonitorClient(syntheticsService, serverMock);
it('should only query enabled monitors', async () => {
const spy = jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([]);
const statusRule = new StatusRuleExecutor(
moment().toDate(),
{},
soClient,
mockEsClient,
serverMock,
monitorClient
);
const { downConfigs, staleDownConfigs } = await statusRule.getDownChecks({});
const mockStart = coreMock.createStart();
const uiSettingsClient = mockStart.uiSettings.asScopedToClient(soClient);
expect(downConfigs).toEqual({});
expect(staleDownConfigs).toEqual({});
const statusRule = new StatusRuleExecutor(serverMock, monitorClient, {
params: {},
services: {
uiSettingsClient,
savedObjectsClient: soClient,
scopedClusterClient: { asCurrentUser: mockEsClient },
},
rule: {
name: 'test',
},
} as any);
expect(spy).toHaveBeenCalledWith({
filter: 'synthetics-monitor.attributes.alert.status.enabled: true',
soClient,
});
});
describe('DefaultRule', () => {
it('should only query enabled monitors', async () => {
const spy = jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([]);
it('marks deleted configs as expected', async () => {
jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue(testMonitors);
const statusRule = new StatusRuleExecutor(
moment().toDate(),
{},
soClient,
mockEsClient,
serverMock,
monitorClient
);
const { downConfigs, staleDownConfigs } = await statusRule.getDownChecks({});
const { downConfigs } = await statusRule.getDownChecks({});
expect(downConfigs).toEqual({});
expect(staleDownConfigs).toEqual({});
expect(downConfigs).toEqual({});
const staleDownConfigs = await statusRule.markDeletedConfigs({
id1: {
locationId: 'us-east-1',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_dev': {
locationId: 'us_central_dev',
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_qa': {
locationId: 'us_central_qa',
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
expect(spy).toHaveBeenCalledWith({
filter: 'synthetics-monitor.attributes.alert.status.enabled: true',
soClient,
});
});
expect(staleDownConfigs).toEqual({
id1: {
configId: 'id1',
isDeleted: true,
locationId: 'us-east-1',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_dev': {
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
isLocationRemoved: true,
locationId: 'us_central_dev',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
});
});
it('marks deleted configs as expected', async () => {
jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue(testMonitors);
it('does not mark deleted config when monitor does not contain location label', async () => {
jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([
{
...testMonitors[0],
attributes: {
...testMonitors[0].attributes,
locations: [
{
geo: { lon: -95.86, lat: 41.25 },
isServiceManaged: true,
id: 'us_central_qa',
},
],
const { downConfigs } = await statusRule.getDownChecks({});
expect(downConfigs).toEqual({});
const staleDownConfigs = await statusRule.markDeletedConfigs({
id2: {
locationId: 'us-east-1',
configId: 'id2',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
},
]);
const statusRule = new StatusRuleExecutor(
moment().toDate(),
{},
soClient,
mockEsClient,
serverMock,
monitorClient
);
'id1-us_central_dev': {
locationId: 'us_central_dev',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
});
const { downConfigs } = await statusRule.getDownChecks({});
expect(downConfigs).toEqual({});
const staleDownConfigs = await statusRule.markDeletedConfigs({
id1: {
locationId: 'us-east-1',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_dev': {
locationId: 'us_central_dev',
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_qa': {
locationId: 'us_central_qa',
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
},
expect(staleDownConfigs).toEqual({
id2: {
configId: 'id2',
isDeleted: true,
locationId: 'us-east-1',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_dev': {
configId: 'id1',
isLocationRemoved: true,
locationId: 'us_central_dev',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
checks: {
downWithinXChecks: 1,
down: 1,
},
},
});
});
expect(staleDownConfigs).toEqual({
id1: {
configId: 'id1',
isDeleted: true,
locationId: 'us-east-1',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
'2548dab3-4752-4b4d-89a2-ae3402b6fb04-us_central_dev': {
configId: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
isLocationRemoved: true,
locationId: 'us_central_dev',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
it('does not mark deleted config when monitor does not contain location label', async () => {
jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([
{
...testMonitors[0],
attributes: {
...testMonitors[0].attributes,
locations: [
{
geo: { lon: -95.86, lat: 41.25 },
isServiceManaged: true,
id: 'us_central_qa',
},
],
},
},
]);
const { downConfigs } = await statusRule.getDownChecks({});
expect(downConfigs).toEqual({});
const staleDownConfigs = await statusRule.markDeletedConfigs({
id2: {
locationId: 'us-east-1',
configId: 'id2',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_dev': {
locationId: 'us_central_dev',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: {} as any,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
});
expect(staleDownConfigs).toEqual({
id2: {
configId: 'id2',
isDeleted: true,
locationId: 'us-east-1',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_dev': {
configId: 'id1',
isLocationRemoved: true,
locationId: 'us_central_dev',
monitorQueryId: 'test',
ping: {},
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
checks: {
downWithinXChecks: 1,
down: 1,
},
},
});
});
});
describe('handleDownMonitorThresholdAlert', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should alert if monitor meet location threshold', async () => {
const spy = jest.spyOn(statusRule, 'scheduleAlert');
statusRule.handleDownMonitorThresholdAlert({
downConfigs: {
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
},
});
expect(spy).toHaveBeenCalledWith({
alertId: 'id1-us_central_qa',
downThreshold: 1,
idWithLocation: 'id1-us_central_qa',
locationNames: ['Test location'],
locationIds: ['test'],
monitorSummary: {
checkedAt: '2024-05-13T12:33:37Z',
checks: { down: 1, downWithinXChecks: 1 },
configId: 'id1',
downThreshold: 1,
hostName: undefined,
lastErrorMessage: undefined,
locationId: 'us_central_qa',
locationName: 'Test location',
locationNames: 'Test location',
monitorId: 'test',
monitorName: 'test monitor',
monitorTags: ['dev'],
monitorType: 'browser',
monitorUrl: 'https://www.google.com',
monitorUrlLabel: 'URL',
reason:
'Monitor "test monitor" from Test location is down. Monitor is down 1 time within the last 1 checks. Alert when 1 out of the last 1 checks are down from at least 1 location.',
stateId: undefined,
status: 'down',
timestamp: '2024-05-13T12:33:37.000Z',
},
statusConfig: {
checks: { down: 1, downWithinXChecks: 1 },
configId: 'id1',
locationId: 'us_central_qa',
monitorQueryId: 'test',
ping: testPing,
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
useLatestChecks: true,
});
});
it('should not alert if monitor do not meet location threshold', async () => {
statusRule.params = {
condition: {
window: {
numberOfChecks: 1,
},
downThreshold: 1,
locationsThreshold: 2,
},
};
const spy = jest.spyOn(statusRule, 'scheduleAlert');
statusRule.handleDownMonitorThresholdAlert({
downConfigs: {
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
},
});
expect(spy).toHaveBeenCalledTimes(0);
});
it('should send 2 alerts', async () => {
statusRule.params = {
condition: {
window: {
numberOfChecks: 1,
},
downThreshold: 1,
locationsThreshold: 1,
},
};
const spy = jest.spyOn(statusRule, 'scheduleAlert');
statusRule.handleDownMonitorThresholdAlert({
downConfigs: {
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_dev': {
locationId: 'us_central_dev',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
},
});
expect(spy).toHaveBeenCalledTimes(2);
});
it('should send 1 alert for un-grouped', async () => {
statusRule.params = {
condition: {
groupBy: 'none',
window: {
numberOfChecks: 1,
},
downThreshold: 1,
locationsThreshold: 1,
},
};
const spy = jest.spyOn(statusRule, 'scheduleAlert');
statusRule.handleDownMonitorThresholdAlert({
downConfigs: {
'id1-us_central_qa': {
locationId: 'us_central_qa',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
'id1-us_central_dev': {
locationId: 'us_central_dev',
configId: 'id1',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
monitorQueryId: 'test',
ping: testPing,
checks: {
downWithinXChecks: 1,
down: 1,
},
},
},
});
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({
alertId: 'id1',
downThreshold: 1,
idWithLocation: 'id1',
locationIds: ['test', 'test'],
locationNames: ['Test location', 'Test location'],
monitorSummary: {
checkedAt: '2024-05-13T12:33:37Z',
checks: { down: 1, downWithinXChecks: 1 },
configId: 'id1',
downThreshold: 1,
hostName: undefined,
lastErrorMessage: undefined,
locationId: 'test and test',
locationName: 'Test location',
locationNames: 'Test location and Test location',
monitorId: 'test',
monitorName: 'test monitor',
monitorTags: ['dev'],
monitorType: 'browser',
monitorUrl: 'https://www.google.com',
monitorUrlLabel: 'URL',
reason:
'Monitor "test monitor" is down 1 time from Test location and 1 time from Test location. Alert when down 1 time out of the last 1 checks from at least 1 location.',
status: 'down',
timestamp: '2024-05-13T12:33:37.000Z',
},
statusConfig: {
checks: { down: 1, downWithinXChecks: 1 },
configId: 'id1',
locationId: 'us_central_qa',
monitorQueryId: 'test',
ping: testPing,
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
useLatestChecks: true,
});
});
});
});
describe('getDoesMonitorMeetLocationThreshold', () => {
describe('when useTimeWindow is false', () => {
it('should return false if monitor does not meets location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 0, downWithinXChecks: 0 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 1,
downThreshold: 1,
useTimeWindow: false,
});
expect(res).toBe(false);
});
it('should return true if monitor meets location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 1 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 1,
downThreshold: 1,
useTimeWindow: false,
});
expect(res).toBe(true);
});
it('should return false if monitor does not meets 2 location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 1 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 2,
downThreshold: 1,
useTimeWindow: false,
});
expect(res).toBe(false);
});
it('should return true if monitor meets 2 location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 1 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
{
checks: { down: 1, downWithinXChecks: 1 },
locationId: 'us_central',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 2,
downThreshold: 1,
useTimeWindow: false,
});
expect(res).toBe(true);
});
});
describe('when useTimeWindow is true', () => {
it('should return false if monitor does not meets location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 0, downWithinXChecks: 0 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 1,
downThreshold: 1,
useTimeWindow: true,
});
expect(res).toBe(false);
});
it('should return true if monitor meets location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 0 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 1,
downThreshold: 1,
useTimeWindow: true,
});
expect(res).toBe(true);
});
it('should return false if monitor does not meets 2 location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 0 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 2,
downThreshold: 1,
useTimeWindow: true,
});
expect(res).toBe(false);
});
it('should return true if monitor meets 2 location threshold', () => {
const matchesByLocation: AlertStatusMetaData[] = [
{
checks: { down: 1, downWithinXChecks: 0 },
locationId: 'us_central_qa',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
{
checks: { down: 1, downWithinXChecks: 1 },
locationId: 'us_central',
ping: testPing,
configId: 'id1',
monitorQueryId: 'test',
status: 'down',
timestamp: '2021-06-01T00:00:00.000Z',
},
];
const res = getDoesMonitorMeetLocationThreshold({
matchesByLocation,
locationsThreshold: 2,
downThreshold: 1,
useTimeWindow: true,
});
expect(res).toBe(true);
});
});
});
@ -226,7 +675,7 @@ describe('StatusRuleExecutor', () => {
const testMonitors = [
{
type: 'synthetics-monitor',
id: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
id: 'id1',
attributes: {
type: 'browser',
form_monitor_type: 'multistep',
@ -234,7 +683,7 @@ const testMonitors = [
alert: { status: { enabled: false } },
schedule: { unit: 'm', number: '10' },
'service.name': '',
config_id: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
config_id: 'id1',
tags: [],
timeout: null,
name: 'https://www.google.com',
@ -250,7 +699,7 @@ const testMonitors = [
origin: 'ui',
journey_id: '',
hash: '',
id: '2548dab3-4752-4b4d-89a2-ae3402b6fb04',
id: 'id1',
project_id: '',
playwright_options: '',
__ui: {
@ -289,3 +738,22 @@ const testMonitors = [
sort: ['https://www.google.com', 1889],
},
] as any;
const testPing = {
'@timestamp': '2024-05-13T12:33:37.000Z',
monitor: {
id: 'test',
name: 'test monitor',
type: 'browser',
},
tags: ['dev'],
url: {
full: 'https://www.google.com',
},
observer: {
name: 'test',
geo: {
name: 'Test location',
},
},
} as any;

View file

@ -9,9 +9,31 @@ import {
SavedObjectsClientContract,
SavedObjectsFindResult,
} from '@kbn/core-saved-objects-api-server';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { AlertOverviewStatus } from '../../../common/runtime_types/alert_rules/common';
import { queryMonitorStatusForAlert } from './query_monitor_status_alert';
import { Logger } from '@kbn/core/server';
import { intersection, isEmpty, uniq } from 'lodash';
import { getAlertDetailsUrl } from '@kbn/observability-plugin/common';
import {
AlertOverviewStatus,
AlertStatusConfigs,
AlertStatusMetaData,
StaleDownConfig,
} from '../../../common/runtime_types/alert_rules/common';
import { queryFilterMonitors } from './queries/filter_monitors';
import { MonitorSummaryStatusRule, StatusRuleExecutorOptions } from './types';
import {
AND_LABEL,
getFullViewInAppMessage,
getRelativeViewInAppUrl,
getViewInAppUrl,
} from '../common';
import {
DOWN_LABEL,
getMonitorAlertDocument,
getMonitorSummary,
getUngroupedReasonMessage,
} from './message_utils';
import { queryMonitorStatusAlert } from './queries/query_monitor_status_alert';
import { parseArrayFilters } from '../../routes/common';
import { SyntheticsServerSetup } from '../../types';
import { SyntheticsEsClient } from '../../lib';
import { SYNTHETICS_INDEX_PATTERN } from '../../../common/constants';
@ -19,11 +41,13 @@ import {
getAllMonitors,
processMonitors,
} from '../../saved_objects/synthetics_monitor/get_all_monitors';
import { StatusRuleParams } from '../../../common/rules/status_rule';
import { getConditionType, StatusRuleParams } from '../../../common/rules/status_rule';
import { ConfigKey, EncryptedSyntheticsMonitorAttributes } from '../../../common/runtime_types';
import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client';
import { monitorAttributes } from '../../../common/types/saved_objects';
import { AlertConfigKey } from '../../../common/constants/monitor_management';
import { ALERT_DETAILS_URL, VIEW_IN_APP_URL } from '../action_variables';
import { MONITOR_STATUS } from '../../../common/constants/synthetics_alerts';
export class StatusRuleExecutor {
previousStartedAt: Date | null;
@ -33,106 +57,176 @@ export class StatusRuleExecutor {
server: SyntheticsServerSetup;
syntheticsMonitorClient: SyntheticsMonitorClient;
monitors: Array<SavedObjectsFindResult<EncryptedSyntheticsMonitorAttributes>> = [];
hasCustomCondition: boolean;
monitorLocationsMap: Record<string, string[]>; // monitorId: locationIds
dateFormat?: string;
tz?: string;
options: StatusRuleExecutorOptions;
logger: Logger;
ruleName: string;
constructor(
previousStartedAt: Date | null,
p: StatusRuleParams,
soClient: SavedObjectsClientContract,
scopedClient: ElasticsearchClient,
server: SyntheticsServerSetup,
syntheticsMonitorClient: SyntheticsMonitorClient
syntheticsMonitorClient: SyntheticsMonitorClient,
options: StatusRuleExecutorOptions
) {
const { services, params, previousStartedAt, rule } = options;
const { scopedClusterClient, savedObjectsClient } = services;
this.ruleName = rule.name;
this.logger = server.logger;
this.previousStartedAt = previousStartedAt;
this.params = p;
this.soClient = soClient;
this.esClient = new SyntheticsEsClient(this.soClient, scopedClient, {
this.params = params;
this.soClient = savedObjectsClient;
this.esClient = new SyntheticsEsClient(this.soClient, scopedClusterClient.asCurrentUser, {
heartbeatIndices: SYNTHETICS_INDEX_PATTERN,
});
this.server = server;
this.syntheticsMonitorClient = syntheticsMonitorClient;
this.hasCustomCondition = !isEmpty(this.params);
this.monitorLocationsMap = {};
this.options = options;
}
debug(message: string) {
this.logger.debug(`[Status Rule Executor][${this.ruleName}] ${message}`);
}
async init() {
const { uiSettingsClient } = this.options.services;
this.dateFormat = await uiSettingsClient.get('dateFormat');
const timezone = await uiSettingsClient.get('dateFormat:tz');
this.tz = timezone === 'Browser' ? 'UTC' : timezone;
}
async getMonitors() {
this.monitors = await getAllMonitors({
soClient: this.soClient,
filter: `${monitorAttributes}.${AlertConfigKey.STATUS_ENABLED}: true`,
const baseFilter = !this.hasCustomCondition
? `${monitorAttributes}.${AlertConfigKey.STATUS_ENABLED}: true`
: '';
const configIds = await queryFilterMonitors({
spaceId: this.options.spaceId,
esClient: this.esClient,
ruleParams: this.params,
});
const {
allIds,
enabledMonitorQueryIds,
monitorLocationIds,
const { filtersStr } = parseArrayFilters({
configIds,
filter: baseFilter,
tags: this.params?.tags,
locations: this.params?.locations,
monitorTypes: this.params?.monitorTypes,
monitorQueryIds: this.params?.monitorIds,
projects: this.params?.projects,
});
this.monitors = await getAllMonitors({
soClient: this.soClient,
filter: filtersStr,
});
this.debug(`Found ${this.monitors.length} monitors for params ${JSON.stringify(this.params)}`);
return processMonitors(this.monitors);
}
async getDownChecks(prevDownConfigs: AlertStatusConfigs = {}): Promise<AlertOverviewStatus> {
await this.init();
const { enabledMonitorQueryIds, maxPeriod, monitorLocationIds, monitorLocationsMap } =
await this.getMonitors();
const range = this.getRange(maxPeriod);
const { numberOfChecks } = getConditionType(this.params.condition);
if (enabledMonitorQueryIds.length === 0) {
const staleDownConfigs = this.markDeletedConfigs(prevDownConfigs);
return {
downConfigs: { ...prevDownConfigs },
upConfigs: {},
staleDownConfigs,
enabledMonitorQueryIds,
pendingConfigs: {},
};
}
const queryLocations = this.params?.locations;
// Account for locations filter
const listOfLocationAfterFilter = queryLocations
? intersection(monitorLocationIds, queryLocations)
: monitorLocationIds;
const currentStatus = await queryMonitorStatusAlert({
esClient: this.esClient,
monitorLocationIds: listOfLocationAfterFilter,
range,
monitorQueryIds: enabledMonitorQueryIds,
numberOfChecks,
monitorLocationsMap,
projectMonitorsCount,
monitorQueryIdToConfigIdMap,
} = processMonitors(this.monitors);
includeRetests: this.params.condition?.includeRetests,
});
const { downConfigs, upConfigs } = currentStatus;
this.debug(
`Found ${Object.keys(downConfigs).length} down configs and ${
Object.keys(upConfigs).length
} up configs`
);
const downConfigsById = getConfigsByIds(downConfigs);
const upConfigsById = getConfigsByIds(upConfigs);
uniq([...downConfigsById.keys(), ...upConfigsById.keys()]).forEach((configId) => {
const downCount = downConfigsById.get(configId)?.length ?? 0;
const upCount = upConfigsById.get(configId)?.length ?? 0;
const name = this.monitors.find((m) => m.id === configId)?.attributes.name ?? configId;
this.debug(
`Monitor: ${name} with id ${configId} has ${downCount} down check and ${upCount} up check`
);
});
Object.keys(prevDownConfigs).forEach((locId) => {
if (!downConfigs[locId] && !upConfigs[locId]) {
downConfigs[locId] = prevDownConfigs[locId];
}
});
const staleDownConfigs = this.markDeletedConfigs(downConfigs);
return {
enabledMonitorQueryIds,
monitorLocationIds,
allIds,
monitorLocationsMap,
projectMonitorsCount,
monitorQueryIdToConfigIdMap,
...currentStatus,
staleDownConfigs,
pendingConfigs: {},
};
}
async getDownChecks(
prevDownConfigs: AlertOverviewStatus['downConfigs'] = {}
): Promise<AlertOverviewStatus> {
const {
monitorLocationIds,
enabledMonitorQueryIds,
monitorLocationsMap,
monitorQueryIdToConfigIdMap,
} = await this.getMonitors();
const from = this.previousStartedAt
getRange = (maxPeriod: number) => {
let from = this.previousStartedAt
? moment(this.previousStartedAt).subtract(1, 'minute').toISOString()
: 'now-2m';
if (enabledMonitorQueryIds.length > 0) {
const currentStatus = await queryMonitorStatusForAlert(
this.esClient,
monitorLocationIds,
{
to: 'now',
from,
},
enabledMonitorQueryIds,
monitorLocationsMap,
monitorQueryIdToConfigIdMap
);
const condition = this.params.condition;
if (condition && 'numberOfChecks' in condition?.window) {
const numberOfChecks = condition.window.numberOfChecks;
from = moment()
.subtract(maxPeriod * numberOfChecks, 'milliseconds')
.subtract(5, 'minutes')
.toISOString();
} else if (condition && 'time' in condition.window) {
const time = condition.window.time;
const { unit, size } = time;
const downConfigs = currentStatus.downConfigs;
const upConfigs = currentStatus.upConfigs;
Object.keys(prevDownConfigs).forEach((locId) => {
if (!downConfigs[locId] && !upConfigs[locId]) {
downConfigs[locId] = prevDownConfigs[locId];
}
});
const staleDownConfigs = this.markDeletedConfigs(downConfigs);
return {
...currentStatus,
staleDownConfigs,
};
from = moment().subtract(size, unit).toISOString();
}
const staleDownConfigs = this.markDeletedConfigs(prevDownConfigs);
return {
downConfigs: { ...prevDownConfigs },
upConfigs: {},
pendingConfigs: {},
staleDownConfigs,
down: 0,
up: 0,
pending: 0,
enabledMonitorQueryIds,
};
}
markDeletedConfigs(downConfigs: AlertOverviewStatus['downConfigs']) {
this.debug(
`Using range from ${from} to now, diff of ${moment().diff(from, 'minutes')} minutes`
);
return { from, to: 'now' };
};
markDeletedConfigs(downConfigs: AlertStatusConfigs): Record<string, StaleDownConfig> {
const monitors = this.monitors;
const staleDownConfigs: AlertOverviewStatus['staleDownConfigs'] = {};
Object.keys(downConfigs).forEach((locPlusId) => {
@ -158,4 +252,221 @@ export class StatusRuleExecutor {
return staleDownConfigs;
}
handleDownMonitorThresholdAlert = ({ downConfigs }: { downConfigs: AlertStatusConfigs }) => {
const { useTimeWindow, useLatestChecks, downThreshold, locationsThreshold } = getConditionType(
this.params?.condition
);
const groupBy = this.params?.condition?.groupBy ?? 'locationId';
if (groupBy === 'locationId' && locationsThreshold === 1) {
Object.entries(downConfigs).forEach(([idWithLocation, statusConfig]) => {
const doesMonitorMeetLocationThreshold = getDoesMonitorMeetLocationThreshold({
matchesByLocation: [statusConfig],
locationsThreshold,
downThreshold,
useTimeWindow: useTimeWindow || false,
});
if (doesMonitorMeetLocationThreshold) {
const alertId = idWithLocation;
const monitorSummary = this.getMonitorDownSummary({
statusConfig,
});
return this.scheduleAlert({
idWithLocation,
alertId,
monitorSummary,
statusConfig,
downThreshold,
useLatestChecks,
locationNames: [statusConfig.ping.observer.geo?.name!],
locationIds: [statusConfig.ping.observer.name!],
});
}
});
} else {
const downConfigsById = getConfigsByIds(downConfigs);
for (const [configId, configs] of downConfigsById) {
const doesMonitorMeetLocationThreshold = getDoesMonitorMeetLocationThreshold({
matchesByLocation: configs,
locationsThreshold,
downThreshold,
useTimeWindow: useTimeWindow || false,
});
if (doesMonitorMeetLocationThreshold) {
const alertId = configId;
const monitorSummary = this.getUngroupedDownSummary({
statusConfigs: configs,
});
return this.scheduleAlert({
idWithLocation: configId,
alertId,
monitorSummary,
statusConfig: configs[0],
downThreshold,
useLatestChecks,
locationNames: configs.map((c) => c.ping.observer.geo?.name!),
locationIds: configs.map((c) => c.ping.observer.name!),
});
}
}
}
};
getMonitorDownSummary({ statusConfig }: { statusConfig: AlertStatusMetaData }) {
const { ping, configId, locationId, checks } = statusConfig;
return getMonitorSummary({
monitorInfo: ping,
statusMessage: DOWN_LABEL,
locationId: [locationId],
configId,
dateFormat: this.dateFormat ?? 'Y-MM-DD HH:mm:ss',
tz: this.tz ?? 'UTC',
checks,
params: this.params,
});
}
getUngroupedDownSummary({ statusConfigs }: { statusConfigs: AlertStatusMetaData[] }) {
const sampleConfig = statusConfigs[0];
const { ping, configId, checks } = sampleConfig;
const baseSummary = getMonitorSummary({
monitorInfo: ping,
statusMessage: DOWN_LABEL,
locationId: statusConfigs.map((c) => c.ping.observer.name!),
configId,
dateFormat: this.dateFormat!,
tz: this.tz!,
checks,
params: this.params,
});
baseSummary.reason = getUngroupedReasonMessage({
statusConfigs,
monitorName: baseSummary.monitorName,
params: this.params,
});
if (statusConfigs.length > 1) {
baseSummary.locationNames = statusConfigs
.map((c) => c.ping.observer.geo?.name!)
.join(` ${AND_LABEL} `);
}
return baseSummary;
}
scheduleAlert({
idWithLocation,
alertId,
monitorSummary,
statusConfig,
downThreshold,
useLatestChecks = false,
locationNames,
locationIds,
}: {
idWithLocation: string;
alertId: string;
monitorSummary: MonitorSummaryStatusRule;
statusConfig: AlertStatusMetaData;
downThreshold: number;
useLatestChecks?: boolean;
locationNames: string[];
locationIds: string[];
}) {
const { configId, locationId, checks } = statusConfig;
const { spaceId, startedAt } = this.options;
const { alertsClient } = this.options.services;
const { basePath } = this.server;
if (!alertsClient) return;
const { uuid: alertUuid, start } = alertsClient.report({
id: alertId,
actionGroup: MONITOR_STATUS.id,
});
const errorStartedAt = start ?? startedAt.toISOString() ?? monitorSummary.timestamp;
let relativeViewInAppUrl = '';
if (monitorSummary.stateId) {
relativeViewInAppUrl = getRelativeViewInAppUrl({
configId,
locationId,
stateId: monitorSummary.stateId,
});
}
const context = {
...monitorSummary,
idWithLocation,
checks,
downThreshold,
errorStartedAt,
linkMessage: monitorSummary.stateId
? getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl)
: '',
[VIEW_IN_APP_URL]: getViewInAppUrl(basePath, spaceId, relativeViewInAppUrl),
[ALERT_DETAILS_URL]: getAlertDetailsUrl(basePath, spaceId, alertUuid),
};
const alertDocument = getMonitorAlertDocument(
monitorSummary,
locationNames,
locationIds,
useLatestChecks
);
alertsClient.setAlertData({
id: alertId,
payload: alertDocument,
context,
});
}
}
export const getDoesMonitorMeetLocationThreshold = ({
matchesByLocation,
locationsThreshold,
downThreshold,
useTimeWindow,
}: {
matchesByLocation: AlertStatusMetaData[];
locationsThreshold: number;
downThreshold: number;
useTimeWindow: boolean;
}) => {
// for location based we need to make sure, monitor is down for the threshold for all locations
const getMatchingLocationsWithDownThresholdWithXChecks = (matches: AlertStatusMetaData[]) => {
return matches.filter((config) => (config.checks?.downWithinXChecks ?? 1) >= downThreshold);
};
const getMatchingLocationsWithDownThresholdWithinTimeWindow = (
matches: AlertStatusMetaData[]
) => {
return matches.filter((config) => (config.checks?.down ?? 1) >= downThreshold);
};
if (useTimeWindow) {
const matchingLocationsWithDownThreshold =
getMatchingLocationsWithDownThresholdWithinTimeWindow(matchesByLocation);
return matchingLocationsWithDownThreshold.length >= locationsThreshold;
} else {
const matchingLocationsWithDownThreshold =
getMatchingLocationsWithDownThresholdWithXChecks(matchesByLocation);
return matchingLocationsWithDownThreshold.length >= locationsThreshold;
}
};
export const getConfigsByIds = (
downConfigs: AlertStatusConfigs
): Map<string, AlertStatusMetaData[]> => {
const downConfigsById = new Map<string, AlertStatusMetaData[]>();
Object.entries(downConfigs).forEach(([_, config]) => {
const { configId } = config;
if (!downConfigsById.has(configId)) {
downConfigsById.set(configId, []);
}
downConfigsById.get(configId)?.push(config);
});
return downConfigsById;
};

View file

@ -5,6 +5,47 @@
* 2.0.
*/
import { ObservabilityUptimeAlert } from '@kbn/alerts-as-data-utils';
import { ActionGroupIdsOf } from '@kbn/alerting-types';
import {
AlertInstanceContext as AlertContext,
RuleExecutorOptions,
} from '@kbn/alerting-plugin/server';
import { StatusRuleParams } from '../../../common/rules/status_rule';
import { MONITOR_STATUS } from '../../../common/constants/synthetics_alerts';
import {
SyntheticsCommonState,
SyntheticsMonitorStatusAlertState,
} from '../../../common/runtime_types/alert_rules/common';
type MonitorStatusRuleTypeParams = StatusRuleParams;
type MonitorStatusActionGroups = ActionGroupIdsOf<typeof MONITOR_STATUS>;
type MonitorStatusRuleTypeState = SyntheticsCommonState;
type MonitorStatusAlertState = SyntheticsMonitorStatusAlertState;
type MonitorStatusAlertContext = AlertContext;
export type StatusRuleExecutorOptions = RuleExecutorOptions<
MonitorStatusRuleTypeParams,
MonitorStatusRuleTypeState,
MonitorStatusAlertState,
MonitorStatusAlertContext,
MonitorStatusActionGroups,
MonitorStatusAlertDocument
>;
export type MonitorStatusAlertDocument = ObservabilityUptimeAlert &
Required<
Pick<
ObservabilityUptimeAlert,
| 'monitor.id'
| 'monitor.type'
| 'monitor.name'
| 'configId'
| 'observer.geo.name'
| 'location.name'
| 'location.id'
>
>;
export interface MonitorSummaryStatusRule {
reason: string;
status: string;
@ -17,8 +58,15 @@ export interface MonitorSummaryStatusRule {
monitorType: string;
monitorName: string;
locationName: string;
lastErrorMessage: string;
stateId: string | null;
locationNames: string;
monitorUrlLabel: string;
monitorTags?: string[];
downThreshold: number;
checks?: {
downWithinXChecks: number;
down: number;
};
stateId?: string;
lastErrorMessage?: string;
timestamp: string;
}

View file

@ -0,0 +1,36 @@
/*
* 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 moment from 'moment';
import { EncryptedSyntheticsMonitorAttributes, OverviewPing } from '../../../common/runtime_types';
export const getMonitorToPing = (
monitor: EncryptedSyntheticsMonitorAttributes,
locationId: string
) => {
const location = monitor.locations.find((loc) => loc.id === locationId);
return {
monitor: {
id: monitor.id,
name: monitor.name,
type: monitor.type,
},
observer: {
name: location?.id,
geo: {
name: location?.label,
},
},
config_id: monitor.config_id,
} as OverviewPing;
};
export const getIntervalFromTimespan = (timespan: { gte: string; lt: string }) => {
const start = moment(timespan.gte);
const end = moment(timespan.lt);
return end.diff(start, 'seconds');
};

View file

@ -88,6 +88,15 @@ export const commonMonitorStateI18: Array<{
}
),
},
{
name: 'locationNames',
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.state.locationNames',
{
defaultMessage: 'Location names from which the checks are performed.',
}
),
},
{
name: 'locationId',
description: i18n.translate(

View file

@ -90,7 +90,7 @@ export class SyntheticsEsClient {
esRequestStatus = RequestStatus.ERROR;
}
const isInspectorEnabled = await this.getInspectEnabled();
if (isInspectorEnabled && this.request) {
if ((isInspectorEnabled || this.isDev) && this.request) {
this.inspectableEsQueries.push(
getInspectResponse({
esError,
@ -102,7 +102,9 @@ export class SyntheticsEsClient {
startTime: startTimeNow,
})
);
}
if (isInspectorEnabled && this.request) {
debugESCall({
startTime,
request: this.request,
@ -218,9 +220,6 @@ export class SyntheticsEsClient {
return {};
}
async getInspectEnabled() {
if (this.isDev) {
return true;
}
if (!this.uiSettings) {
return false;
}

View file

@ -5,8 +5,43 @@
* 2.0.
*/
import { parseArrayFilters } from './common';
import { getSavedObjectKqlFilter } from './common';
describe('common utils', () => {
it('tests parseArrayFilters', () => {
const filters = parseArrayFilters({
configIds: ['1 4', '2 6', '5'],
});
expect(filters.filtersStr).toMatchInlineSnapshot(
`"synthetics-monitor.attributes.config_id:(\\"1 4\\" OR \\"2 6\\" OR \\"5\\")"`
);
});
it('tests parseArrayFilters with tags and configIds', () => {
const filters = parseArrayFilters({
configIds: ['1', '2'],
tags: ['tag1', 'tag2'],
});
expect(filters.filtersStr).toMatchInlineSnapshot(
`"synthetics-monitor.attributes.tags:(\\"tag1\\" OR \\"tag2\\") AND synthetics-monitor.attributes.config_id:(\\"1\\" OR \\"2\\")"`
);
});
it('tests parseArrayFilters with all options', () => {
const filters = parseArrayFilters({
configIds: ['1', '2'],
tags: ['tag1', 'tag2'],
locations: ['loc1', 'loc2'],
monitorTypes: ['type1', 'type2'],
projects: ['project1', 'project2'],
monitorQueryIds: ['query1', 'query2'],
schedules: ['schedule1', 'schedule2'],
});
expect(filters.filtersStr).toMatchInlineSnapshot(
`"synthetics-monitor.attributes.tags:(\\"tag1\\" OR \\"tag2\\") AND synthetics-monitor.attributes.project_id:(\\"project1\\" OR \\"project2\\") AND synthetics-monitor.attributes.type:(\\"type1\\" OR \\"type2\\") AND synthetics-monitor.attributes.schedule.number:(\\"schedule1\\" OR \\"schedule2\\") AND synthetics-monitor.attributes.id:(\\"query1\\" OR \\"query2\\") AND synthetics-monitor.attributes.config_id:(\\"1\\" OR \\"2\\")"`
);
});
});
describe('getSavedObjectKqlFilter', () => {
it('returns empty string if no values are provided', () => {
expect(getSavedObjectKqlFilter({ field: 'tags' })).toBe('');

View file

@ -7,6 +7,7 @@
import { schema, TypeOf } from '@kbn/config-schema';
import { SavedObjectsFindResponse } from '@kbn/core/server';
import { isEmpty } from 'lodash';
import { escapeQuotes } from '@kbn/es-query';
import { RouteContext } from './types';
import { MonitorSortFieldSchema } from '../../common/runtime_types/monitor_management/sort_field';
@ -110,16 +111,7 @@ export const getMonitors = async (
return context.savedObjectsClient.find(findParams);
};
export const getMonitorFilters = async ({
tags,
filter,
locations,
projects,
monitorTypes,
schedules,
monitorQueryIds,
context,
}: {
interface Filters {
filter?: string;
tags?: string | string[];
monitorTypes?: string | string[];
@ -127,10 +119,35 @@ export const getMonitorFilters = async ({
projects?: string | string[];
schedules?: string | string[];
monitorQueryIds?: string | string[];
context: RouteContext;
}) => {
}
export const getMonitorFilters = async (
data: {
context: RouteContext;
} & Filters
) => {
const { context, locations } = data;
const locationFilter = await parseLocationFilter(context, locations);
return parseArrayFilters({
...data,
locationFilter,
});
};
export const parseArrayFilters = ({
tags,
filter,
configIds,
projects,
monitorTypes,
schedules,
monitorQueryIds,
locationFilter,
}: Filters & {
locationFilter?: string | string[];
configIds?: string[];
}) => {
const filtersStr = [
filter,
getSavedObjectKqlFilter({ field: 'tags', values: tags }),
@ -139,9 +156,11 @@ export const getMonitorFilters = async ({
getSavedObjectKqlFilter({ field: 'locations.id', values: locationFilter }),
getSavedObjectKqlFilter({ field: 'schedule.number', values: schedules }),
getSavedObjectKqlFilter({ field: 'id', values: monitorQueryIds }),
getSavedObjectKqlFilter({ field: 'config_id', values: configIds }),
]
.filter((f) => !!f)
.join(' AND ');
return { filtersStr, locationFilter };
};
@ -156,7 +175,11 @@ export const getSavedObjectKqlFilter = ({
operator?: string;
searchAtRoot?: boolean;
}) => {
if (!values) {
if (values === 'All' || (Array.isArray(values) && values?.includes('All'))) {
return undefined;
}
if (isEmpty(values) || !values) {
return '';
}
let fieldKey = '';

View file

@ -17,21 +17,32 @@ import {
LocationStatus,
} from '../../common/runtime_types';
export const getDevLocation = (devUrl: string): PublicLocation => ({
id: 'dev',
label: 'Dev Service',
geo: { lat: 0, lon: 0 },
url: devUrl,
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
});
export const getDevLocation = (devUrl: string): PublicLocation[] => [
{
id: 'dev',
label: 'Dev Service',
geo: { lat: 0, lon: 0 },
url: devUrl,
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
},
{
id: 'dev2',
label: 'Dev Service 2',
geo: { lat: 0, lon: 0 },
url: devUrl,
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
},
];
export async function getServiceLocations(server: SyntheticsServerSetup) {
let locations: PublicLocations = [];
if (server.config.service?.devUrl) {
locations = [getDevLocation(server.config.service.devUrl)];
locations = getDevLocation(server.config.service.devUrl);
}
const manifestUrl = server.config.service?.manifestUrl;

View file

@ -200,6 +200,18 @@ describe('SyntheticsService', () => {
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
},
{
geo: {
lat: 0,
lon: 0,
},
id: 'dev2',
isInvalid: false,
isServiceManaged: true,
label: 'Dev Service 2',
status: 'experimental',
url: 'http://localhost',
},
]);
});

View file

@ -98,6 +98,10 @@
"@kbn/presentation-util-plugin",
"@kbn/core-application-browser",
"@kbn/dashboard-plugin",
"@kbn/search-types",
"@kbn/slo-schema",
"@kbn/alerting-types",
"@kbn/babel-register",
"@kbn/slo-plugin",
"@kbn/ebt-tools",
"@kbn/alerting-types"

View file

@ -17,8 +17,14 @@ export const uptimeRuleFieldMap: FieldMap = {
type: 'keyword',
required: false,
},
'observer.name': {
type: 'keyword',
array: true,
required: false,
},
'observer.geo.name': {
type: 'keyword',
array: true,
required: false,
},
// monitor status alert fields
@ -43,6 +49,10 @@ export const uptimeRuleFieldMap: FieldMap = {
array: true,
required: false,
},
'monitor.state.id': {
type: 'keyword',
required: false,
},
configId: {
type: 'keyword',
required: false,
@ -53,10 +63,12 @@ export const uptimeRuleFieldMap: FieldMap = {
},
'location.id': {
type: 'keyword',
array: true,
required: false,
},
'location.name': {
type: 'keyword',
array: true,
required: false,
},
// tls alert fields

View file

@ -113,7 +113,10 @@ const mockCommonAlertDocumentFields = (monitorInfo: GetMonitorStatusResult['moni
'monitor.name': monitorInfo.monitor.name || monitorInfo.monitor.id,
'monitor.type': monitorInfo.monitor.type,
'url.full': monitorInfo.url?.full,
'observer.geo.name': monitorInfo.observer?.geo?.name,
'observer.geo.name': monitorInfo.observer?.geo?.name
? [monitorInfo.observer.geo.name]
: undefined,
'observer.name': [],
});
const mockStatusAlertDocument = (

View file

@ -213,8 +213,8 @@ export const getMonitorAlertDocument = (monitorSummary: MonitorSummary) => ({
'monitor.name': monitorSummary.monitorName,
'monitor.tags': monitorSummary.tags,
'url.full': monitorSummary.monitorUrl,
'observer.geo.name': monitorSummary.observerLocation,
'observer.name': monitorSummary.observerName,
'observer.geo.name': [monitorSummary.observerLocation],
'observer.name': [monitorSummary.observerName!],
'error.message': monitorSummary.latestErrorMessage,
'agent.name': monitorSummary.observerHostname,
[ALERT_REASON]: monitorSummary.reason,

View file

@ -43888,11 +43888,9 @@
"xpack.synthetics.alertRules.monitorStatus.actionVariables.state.status": "Statut du moniteur (par ex. \"arrêté\").",
"xpack.synthetics.alertRules.monitorStatus.browser.label": "navigateur",
"xpack.synthetics.alertRules.monitorStatus.host.label": "Hôte",
"xpack.synthetics.alertRules.monitorStatus.reasonMessage": "Le moniteur \"{name}\" de {location} est {status}. Vérifié à {checkedAt}.",
"xpack.synthetics.alertRules.monitorStatus.unavailableUrlLabel": "(indisponible)",
"xpack.synthetics.alerts.monitorStatus.absoluteLink.label": "- Lien",
"xpack.synthetics.alerts.monitorStatus.defaultRecovery.status": "a récupéré",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.reason": "le moniteur a été supprimé",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.status": "a été supprimé",
"xpack.synthetics.alerts.monitorStatus.downLabel": "bas",
"xpack.synthetics.alerts.monitorStatus.relativeLink.label": "- Lien relatif",
@ -43902,7 +43900,6 @@
"xpack.synthetics.alerts.monitorStatus.upCheck.status": "est désormais disponible",
"xpack.synthetics.alerts.settings.addConnector": "Ajouter un connecteur",
"xpack.synthetics.alerts.syntheticsMonitorStatus.clientName": "Statut du moniteur",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoverySubjectMessage": "\"{monitorName}\" ({locationName}) {recoveryStatus} - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorStatus.description": "Alerte lorsqu'un moniteur est arrêté.",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultRecoverySubjectMessage": "L'alerte a été résolue pour le certificat {commonName} - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultSubjectMessage": "L'alerte a été déclenchée pour le certificat {commonName} - Elastic Synthetics",

View file

@ -43627,11 +43627,9 @@
"xpack.synthetics.alertRules.monitorStatus.actionVariables.state.status": "監視ステータス(例:「ダウン」)。",
"xpack.synthetics.alertRules.monitorStatus.browser.label": "ブラウザー",
"xpack.synthetics.alertRules.monitorStatus.host.label": "ホスト",
"xpack.synthetics.alertRules.monitorStatus.reasonMessage": "{location}のモニター\"{name}\"は{status}です。{checkedAt}に確認されました。",
"xpack.synthetics.alertRules.monitorStatus.unavailableUrlLabel": "(使用不可)",
"xpack.synthetics.alerts.monitorStatus.absoluteLink.label": "- リンク",
"xpack.synthetics.alerts.monitorStatus.defaultRecovery.status": "回復しました",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.reason": "モニターが削除されました",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.status": "が削除されました",
"xpack.synthetics.alerts.monitorStatus.downLabel": "ダウン",
"xpack.synthetics.alerts.monitorStatus.relativeLink.label": "- 相対リンク",
@ -43641,10 +43639,6 @@
"xpack.synthetics.alerts.monitorStatus.upCheck.status": "現在起動しています",
"xpack.synthetics.alerts.settings.addConnector": "コネクターの追加",
"xpack.synthetics.alerts.syntheticsMonitorStatus.clientName": "監視ステータス",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultActionMessage": "{locationName}の\"{monitorName}\"は{status}です - Elastic Synthetics\n\n詳細\n\n- モニター名:{monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- モニタータイプ:{monitorType} \n- 確認日時:{checkedAt} \n- 開始場所:{locationName} \n- 受信したエラー:{lastErrorMessage} \n{linkMessage}",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoveryMessage": "{locationName}の\"{monitorName}\"のアラートはアクティブではありません:{recoveryReason} - Elastic Synthetics\n\n詳細\n\n- モニター名:{monitorName} \n- {monitorUrlLabel}: {monitorUrl} \n- モニタータイプ:{monitorType} \n- 開始場所:{locationName} \n- 前回受信したエラー:{lastErrorMessage} \n{linkMessage}",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoverySubjectMessage": "\"{monitorName}\" ({locationName}) {recoveryStatus} - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultSubjectMessage": "\"{monitorName}\" ({locationName})は停止しています - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorStatus.description": "モニターがダウンしているときにアラートを通知します。",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultRecoverySubjectMessage": "証明書{commonName}のアラートが解決しました - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultSubjectMessage": "証明書{commonName}のアラートがトリガーされました - Elastic Synthetics",
@ -44486,8 +44480,6 @@
"xpack.synthetics.rules.tls.agingLabel": "古すぎます",
"xpack.synthetics.rules.tls.clientName": "シンセティックTLS",
"xpack.synthetics.rules.tls.criteriaExpression.ariaLabel": "このアラートで監視されているモニターの条件を示す式",
"xpack.synthetics.rules.tls.defaultActionMessage": "TLS証明書{commonName} {status} - Elastic Synthetics\n\n詳細\n\n- 概要:{summary}\n- 共通名:{commonName}\n- 発行元:{issuer}\n- モニター:{monitorName} \n- モニターURL{monitorUrl} \n- モニタータイプ:{monitorType} \n- 開始場所:{locationName}",
"xpack.synthetics.rules.tls.defaultRecoveryMessage": "モニター\"{monitorName}\"のTLSアラートが回復しました - Elastic Synthetics\n\n詳細\n\n- 概要:{summary}\n- 新しいステータス:{newStatus}\n- 前のステータス:{previousStatus}\n- モニター:{monitorName} \n- URL{monitorUrl} \n- モニタータイプ:{monitorType} \n- 開始場所:{locationName}",
"xpack.synthetics.rules.tls.description": "シンセティック監視のTLS証明書の有効期限が近いときにアラートを発行します。",
"xpack.synthetics.rules.tls.expiredLabel": "有効期限切れです",
"xpack.synthetics.rules.tls.expiringLabel": "まもなく有効期限切れです",

View file

@ -43678,11 +43678,9 @@
"xpack.synthetics.alertRules.monitorStatus.actionVariables.state.status": "监测状态(例如“关闭”)。",
"xpack.synthetics.alertRules.monitorStatus.browser.label": "浏览器",
"xpack.synthetics.alertRules.monitorStatus.host.label": "主机",
"xpack.synthetics.alertRules.monitorStatus.reasonMessage": "来自 {location} 的监测“{name}”为 {status} 状态。已于 {checkedAt} 检查。",
"xpack.synthetics.alertRules.monitorStatus.unavailableUrlLabel": "(不可用)",
"xpack.synthetics.alerts.monitorStatus.absoluteLink.label": "- 链接",
"xpack.synthetics.alerts.monitorStatus.defaultRecovery.status": "已恢复",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.reason": "此监测已删除",
"xpack.synthetics.alerts.monitorStatus.deleteMonitor.status": "已删除",
"xpack.synthetics.alerts.monitorStatus.downLabel": "关闭",
"xpack.synthetics.alerts.monitorStatus.relativeLink.label": "- 相对链接",
@ -43692,10 +43690,6 @@
"xpack.synthetics.alerts.monitorStatus.upCheck.status": "现已打开",
"xpack.synthetics.alerts.settings.addConnector": "添加连接器",
"xpack.synthetics.alerts.syntheticsMonitorStatus.clientName": "监测状态",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultActionMessage": "来自 {locationName} 的“{monitorName}”为 {status}。- Elastic Synthetics\n\n详情\n\n- 监测名称:{monitorName} \n- {monitorUrlLabel}{monitorUrl} \n- 监测类型:{monitorType} \n- 检查时间:{checkedAt} \n- 来自:{locationName} \n- 收到错误:{lastErrorMessage} \n{linkMessage}",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoveryMessage": "来自 {locationName} 的“{monitorName}”的告警不再处于活动状态:{recoveryReason}。- Elastic Synthetics\n\n详情\n\n- 监测名称:{monitorName} \n- {monitorUrlLabel}{monitorUrl} \n- 监测类型:{monitorType} \n- 来自:{locationName} \n- 收到的上一个错误:{lastErrorMessage} \n{linkMessage}",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoverySubjectMessage": "“{monitorName}”({locationName}) {recoveryStatus} - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorStatus.defaultSubjectMessage": "“{monitorName}”({locationName}) 已关闭 - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorStatus.description": "监测关闭时告警。",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultRecoverySubjectMessage": "告警已解析证书 {commonName} - Elastic Synthetics",
"xpack.synthetics.alerts.syntheticsMonitorTLS.defaultSubjectMessage": "已针对证书 {commonName} 触发告警 - Elastic Synthetics",
@ -44537,8 +44531,6 @@
"xpack.synthetics.rules.tls.agingLabel": "过旧",
"xpack.synthetics.rules.tls.clientName": "Synthetics TLS",
"xpack.synthetics.rules.tls.criteriaExpression.ariaLabel": "显示正由此告警监视的监测条件的表达式",
"xpack.synthetics.rules.tls.defaultActionMessage": "TLS 证书 {commonName} {status} - Elastic Synthetics\n\n详情\n\n- 摘要:{summary}\n- 常见名称:{commonName}\n- 颁发者:{issuer}\n- 监测:{monitorName} \n- 监测 URL{monitorUrl} \n- 监测类型:{monitorType} \n- 来自:{locationName}",
"xpack.synthetics.rules.tls.defaultRecoveryMessage": "监测“{monitorName}”的 TLS 告警已恢复 - Elastic Synthetics\n\n详情\n\n- 摘要:{summary}\n- 新状态:{newStatus}\n- 之前的状态:{previousStatus}\n- 监测:{monitorName} \n- URL{monitorUrl} \n- 监测类型:{monitorType} \n- 来自:{locationName}",
"xpack.synthetics.rules.tls.description": "Synthetics 监测的 TLS 证书即将到期时告警。",
"xpack.synthetics.rules.tls.expiredLabel": "已过期",
"xpack.synthetics.rules.tls.expiringLabel": "即将到期",

View file

@ -24,6 +24,7 @@ import { ClosablePopoverTitle } from './components';
import { IErrorObject } from '../../types';
export interface ForLastExpressionProps {
description?: string;
timeWindowSize?: number;
timeWindowUnit?: string;
errors: IErrorObject;
@ -45,6 +46,12 @@ export interface ForLastExpressionProps {
display?: 'fullWidth' | 'inline';
}
const FOR_LAST_LABEL = i18n.translate(
'xpack.triggersActionsUI.common.expressionItems.forTheLast.descriptionLabel',
{
defaultMessage: 'for the last',
}
);
export const ForLastExpression = ({
timeWindowSize,
timeWindowUnit = 's',
@ -53,6 +60,7 @@ export const ForLastExpression = ({
onChangeWindowSize,
onChangeWindowUnit,
popupPosition,
description = FOR_LAST_LABEL,
}: ForLastExpressionProps) => {
const [alertDurationPopoverOpen, setAlertDurationPopoverOpen] = useState(false);
@ -60,12 +68,7 @@ export const ForLastExpression = ({
<EuiPopover
button={
<EuiExpression
description={i18n.translate(
'xpack.triggersActionsUI.common.expressionItems.forTheLast.descriptionLabel',
{
defaultMessage: 'for the last',
}
)}
description={description}
data-test-subj="forLastExpression"
value={`${timeWindowSize ?? '?'} ${getTimeUnitLabel(
timeWindowUnit as TIME_UNITS,
@ -98,12 +101,12 @@ export const ForLastExpression = ({
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
isInvalid={Number(errors.timeWindowSize.length) > 0}
isInvalid={Number(errors.timeWindowSize?.length) > 0}
error={errors.timeWindowSize as string[]}
>
<EuiFieldNumber
data-test-subj="timeWindowSizeNumber"
isInvalid={Number(errors.timeWindowSize.length) > 0}
isInvalid={Number(errors.timeWindowSize?.length) > 0}
min={0}
value={timeWindowSize || ''}
onChange={(e) => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { ReactNode, useState } from 'react';
import {
EuiExpression,
EuiPopover,
@ -20,6 +20,7 @@ import { IErrorObject } from '../../types';
export interface ValueExpressionProps {
description: string;
value: number;
valueLabel?: string | ReactNode;
onChangeSelectedValue: (updatedValue: number) => void;
popupPosition?:
| 'upCenter'
@ -41,6 +42,7 @@ export interface ValueExpressionProps {
export const ValueExpression = ({
description,
value,
valueLabel,
onChangeSelectedValue,
display = 'inline',
popupPosition,
@ -53,7 +55,7 @@ export const ValueExpression = ({
<EuiExpression
data-test-subj="valueExpression"
description={description}
value={value}
value={valueLabel ?? value}
isActive={valuePopoverOpen}
display={display === 'inline' ? 'inline' : 'columns'}
onClick={() => {

View file

@ -354,6 +354,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
'--xpack.task_manager.allow_reading_invalid_state=false',
'--xpack.actions.queued.max=500',
`--xpack.stack_connectors.enableExperimental=${JSON.stringify(experimentalFeatures)}`,
'--xpack.uptime.service.password=test',
'--xpack.uptime.service.username=localKibanaIntegrationTestsUser',
'--xpack.uptime.service.devUrl=mockDevUrl',
'--xpack.uptime.service.manifestUrl=mockDevUrl',
],
},
};

View file

@ -7,6 +7,7 @@
import type { Client } from '@elastic/elasticsearch';
import type { Agent as SuperTestAgent } from 'supertest';
import expect from '@kbn/expect';
import { ToolingLog } from '@kbn/tooling-log';
import { ThresholdParams } from '@kbn/observability-plugin/common/custom_threshold_rule/types';
import { refreshSavedObjectIndices } from './refresh_index';
@ -62,7 +63,7 @@ export async function createRule<Params = ThresholdParams>({
logger: ToolingLog;
esClient: Client;
}) {
const { body } = await supertest
const { body, status } = await supertest
.post(`/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send({
@ -75,8 +76,9 @@ export async function createRule<Params = ThresholdParams>({
name,
rule_type_id: ruleTypeId,
actions,
})
.expect(200);
});
expect(status).to.eql(200, JSON.stringify(body));
await refreshSavedObjectIndices(esClient);
logger.debug(`Created rule id: ${body.id}`);

View file

@ -14,6 +14,7 @@ import type {
SearchResponse,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { RetryService } from '@kbn/ftr-common-functional-services';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { retry } from '../../common/retry';
const TIMEOUT = 70_000;
@ -63,6 +64,7 @@ export async function waitForDocumentInIndex<T>({
timeout = TIMEOUT,
retries = RETRIES,
retryDelay = RETRY_DELAY,
filters,
}: {
esClient: Client;
indexName: string;
@ -72,6 +74,7 @@ export async function waitForDocumentInIndex<T>({
timeout?: number;
retries?: number;
retryDelay?: number;
filters?: QueryDslQueryContainer[];
}): Promise<SearchResponse<T, Record<string, AggregationsAggregate>>> {
return await retry<SearchResponse<T, Record<string, AggregationsAggregate>>>({
testFn: async () => {
@ -79,6 +82,15 @@ export async function waitForDocumentInIndex<T>({
index: indexName,
rest_total_hits_as_int: true,
ignore_unavailable: true,
body: filters
? {
query: {
bool: {
filter: filters,
},
},
}
: undefined,
});
if (!response.hits.total || (response.hits.total as number) < docCountTarget) {
logger.debug(`Document count is ${response.hits.total}, should be ${docCountTarget}`);
@ -104,12 +116,16 @@ export async function waitForAlertInIndex<T>({
ruleId,
retryService,
logger,
filters = [],
retryDelay,
}: {
esClient: Client;
indexName: string;
ruleId: string;
retryService: RetryService;
logger: ToolingLog;
filters?: QueryDslQueryContainer[];
retryDelay?: number;
}): Promise<SearchResponse<T, Record<string, AggregationsAggregate>>> {
return await retry<SearchResponse<T, Record<string, AggregationsAggregate>>>({
testFn: async () => {
@ -117,14 +133,21 @@ export async function waitForAlertInIndex<T>({
index: indexName,
body: {
query: {
term: {
'kibana.alert.rule.uuid': ruleId,
bool: {
filter: [
{
term: {
'kibana.alert.rule.uuid': ruleId,
},
},
...filters,
],
},
},
},
});
if (response.hits.hits.length === 0) {
throw new Error('No hits found');
throw new Error(`No hits found for the ruleId: ${ruleId}`);
}
return response;
},
@ -133,6 +156,6 @@ export async function waitForAlertInIndex<T>({
retryService,
timeout: TIMEOUT,
retries: RETRIES,
retryDelay: RETRY_DELAY,
retryDelay: retryDelay ?? RETRY_DELAY,
});
}

View file

@ -21,7 +21,8 @@ export default function ({ loadTestFile }: any) {
loadTestFile(require.resolve('./custom_threshold_rule_data_view'));
});
describe('Synthetics', () => {
loadTestFile(require.resolve('./synthetics_rule'));
loadTestFile(require.resolve('./synthetics/synthetics_default_rule'));
loadTestFile(require.resolve('./synthetics/custom_status_rule'));
});
});
}

View file

@ -5,132 +5,13 @@
* 2.0.
*/
import expect from '@kbn/expect';
import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants';
import { SanitizedRule } from '@kbn/alerting-plugin/common';
import { omit } from 'lodash';
import { TlsTranslations } from '@kbn/synthetics-plugin/common/rules/synthetics/translations';
import { FtrProviderContext } from '../common/ftr_provider_context';
import {
SyntheticsMonitorStatusTranslations,
TlsTranslations,
} from '@kbn/synthetics-plugin/common/rules/synthetics/translations';
import { SanitizedRule } from '@kbn/alerting-types';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const server = getService('kibanaServer');
const testActions = [
'custom.ssl.noCustom',
'notification-email',
'preconfigured-es-index-action',
'my-deprecated-servicenow',
'my-slack1',
];
describe('SyntheticsRules', () => {
before(async () => {
await server.savedObjects.cleanStandardList();
});
after(async () => {
await server.savedObjects.cleanStandardList();
});
it('creates rule when settings are configured', async () => {
await supertest
.put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS)
.set('kbn-xsrf', 'true')
.send({
certExpirationThreshold: 30,
certAgeThreshold: 730,
defaultConnectors: testActions.slice(0, 2),
defaultEmail: { to: ['test@gmail.com'], cc: [], bcc: [] },
})
.expect(200);
const response = await supertest
.post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING)
.set('kbn-xsrf', 'true')
.send();
const statusResult = response.body.statusRule;
const tlsResult = response.body.tlsRule;
expect(statusResult.actions.length).eql(4);
expect(tlsResult.actions.length).eql(4);
compareRules(statusResult, statusRule);
compareRules(tlsResult, tlsRule);
testActions.slice(0, 2).forEach((action) => {
const { recoveredAction, firingAction } = getActionById(statusRule, action);
const resultAction = getActionById(statusResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
testActions.slice(0, 2).forEach((action) => {
const { recoveredAction, firingAction } = getActionById(tlsRule, action);
const resultAction = getActionById(tlsResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
});
it('updates rules when settings are updated', async () => {
await supertest
.put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS)
.set('kbn-xsrf', 'true')
.send({
certExpirationThreshold: 30,
certAgeThreshold: 730,
defaultConnectors: testActions,
defaultEmail: { to: ['test@gmail.com'], cc: [], bcc: [] },
})
.expect(200);
const response = await supertest
.put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING)
.set('kbn-xsrf', 'true')
.send();
const statusResult = response.body.statusRule;
const tlsResult = response.body.tlsRule;
expect(statusResult.actions.length).eql(9);
expect(tlsResult.actions.length).eql(9);
compareRules(statusResult, statusRule);
compareRules(tlsResult, tlsRule);
testActions.forEach((action) => {
const { recoveredAction, firingAction } = getActionById(statusRule, action);
const resultAction = getActionById(statusResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
testActions.forEach((action) => {
const { recoveredAction, firingAction } = getActionById(tlsRule, action);
const resultAction = getActionById(tlsResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
});
});
}
const compareRules = (rule1: SanitizedRule, rule2: SanitizedRule) => {
expect(rule1.alertTypeId).eql(rule2.alertTypeId);
expect(rule1.schedule).eql(rule2.schedule);
};
const getActionById = (rule: SanitizedRule, id: string) => {
const actions = rule.actions.filter((action) => action.id === id);
const recoveredAction = actions.find(
(action) => 'group' in action && action.group === 'recovered'
);
const firingAction = actions.find((action) => 'group' in action && action.group !== 'recovered');
return {
recoveredAction: omit(recoveredAction, ['uuid']),
firingAction: omit(firingAction, ['uuid']),
};
};
const statusRule = {
export const statusRule = {
id: 'dbbc39f0-1781-11ee-80b9-6522650f1d50',
notifyWhen: null,
consumer: 'uptime',
@ -152,7 +33,7 @@ const statusRule = {
{
group: 'recovered',
params: {
body: 'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
body: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage,
},
frequency: { notifyWhen: 'onActionGroupChange', throttle: null, summary: false },
uuid: '789f2b81-e098-4f33-9802-1d355f4fabbe',
@ -162,7 +43,7 @@ const statusRule = {
{
group: 'xpack.synthetics.alerts.actionGroups.monitorStatus',
params: {
body: '"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
body: SyntheticsMonitorStatusTranslations.defaultActionMessage,
},
frequency: { notifyWhen: 'onActionGroupChange', throttle: null, summary: false },
uuid: '1b3f3958-f019-4ca0-b6b1-ccc4cf51d501',
@ -173,10 +54,8 @@ const statusRule = {
group: 'recovered',
params: {
to: ['test@gmail.com'],
subject:
'"{{context.monitorName}}" ({{context.locationName}}) {{context.recoveryStatus}} - Elastic Synthetics',
message:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
subject: SyntheticsMonitorStatusTranslations.defaultRecoverySubjectMessage,
message: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage,
messageHTML: null,
cc: [],
bcc: [],
@ -191,10 +70,8 @@ const statusRule = {
group: 'xpack.synthetics.alerts.actionGroups.monitorStatus',
params: {
to: ['test@gmail.com'],
subject:
'"{{context.monitorName}}" ({{context.locationName}}) is down - Elastic Synthetics',
message:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
subject: SyntheticsMonitorStatusTranslations.defaultSubjectMessage,
message: SyntheticsMonitorStatusTranslations.defaultActionMessage,
messageHTML: null,
cc: [],
bcc: [],
@ -250,10 +127,8 @@ const statusRule = {
subAction: 'pushToService',
subActionParams: {
incident: {
short_description:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
description:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
short_description: SyntheticsMonitorStatusTranslations.defaultActionMessage,
description: SyntheticsMonitorStatusTranslations.defaultActionMessage,
impact: '2',
severity: '2',
urgency: '2',
@ -275,8 +150,7 @@ const statusRule = {
{
group: 'recovered',
params: {
message:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
message: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage,
},
frequency: { notifyWhen: 'onActionGroupChange', throttle: null, summary: false },
uuid: '2d73f370-a90c-4347-8480-753cbeae719f',
@ -286,8 +160,7 @@ const statusRule = {
{
group: 'xpack.synthetics.alerts.actionGroups.monitorStatus',
params: {
message:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
message: SyntheticsMonitorStatusTranslations.defaultActionMessage,
},
frequency: { notifyWhen: 'onActionGroupChange', throttle: null, summary: false },
uuid: '1c5d0dd1-c360-4e14-8e4f-f24aa5c640c6',
@ -339,7 +212,8 @@ const statusRule = {
},
ruleTypeId: 'xpack.synthetics.alerts.monitorStatus',
} as unknown as SanitizedRule;
const tlsRule = {
export const tlsRule = {
id: 'dbbc12e0-1781-11ee-80b9-6522650f1d50',
notifyWhen: null,
consumer: 'uptime',

View file

@ -0,0 +1,83 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { privateLocationsSavedObjectName } from '@kbn/synthetics-plugin/common/saved_objects/private_locations';
import { privateLocationsSavedObjectId } from '@kbn/synthetics-plugin/server/saved_objects/private_locations';
import { SyntheticsPrivateLocations } from '@kbn/synthetics-plugin/common/runtime_types';
import { Agent as SuperTestAgent } from 'supertest';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export const INSTALLED_VERSION = '1.1.1';
export class PrivateLocationTestService {
private supertest: SuperTestAgent;
private readonly getService: FtrProviderContext['getService'];
constructor(getService: FtrProviderContext['getService']) {
this.supertest = getService('supertest');
this.getService = getService;
}
async installSyntheticsPackage() {
await this.supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200);
const response = await this.supertest
.get(`/api/fleet/epm/packages/synthetics/${INSTALLED_VERSION}`)
.set('kbn-xsrf', 'true')
.expect(200);
if (response.body.item.status !== 'installed') {
await this.supertest
.post(`/api/fleet/epm/packages/synthetics/${INSTALLED_VERSION}`)
.set('kbn-xsrf', 'true')
.send({ force: true })
.expect(200);
}
}
async addTestPrivateLocation() {
const apiResponse = await this.addFleetPolicy(uuidv4());
const testPolicyId = apiResponse.body.item.id;
return (await this.setTestLocations([testPolicyId]))[0];
}
async addFleetPolicy(name: string) {
return this.supertest
.post('/api/fleet/agent_policies?sys_monitoring=true')
.set('kbn-xsrf', 'true')
.send({
name,
description: '',
namespace: 'default',
monitoring_enabled: [],
})
.expect(200);
}
async setTestLocations(testFleetPolicyIds: string[]) {
const server = this.getService('kibanaServer');
const locations: SyntheticsPrivateLocations = testFleetPolicyIds.map((id, index) => ({
label: 'Test private location ' + index,
agentPolicyId: id,
id,
geo: {
lat: 0,
lon: 0,
},
isServiceManaged: false,
}));
await server.savedObjects.create({
type: privateLocationsSavedObjectName,
id: privateLocationsSavedObjectId,
attributes: {
locations,
},
overwrite: true,
});
return locations;
}
}

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 expect from '@kbn/expect';
import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants';
import { SanitizedRule } from '@kbn/alerting-plugin/common';
import { omit } from 'lodash';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { statusRule, tlsRule } from './data';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const server = getService('kibanaServer');
const testActions = [
'custom.ssl.noCustom',
'notification-email',
'preconfigured-es-index-action',
'my-deprecated-servicenow',
'my-slack1',
];
describe('SyntheticsDefaultRules', () => {
before(async () => {
await server.savedObjects.cleanStandardList();
});
after(async () => {
await server.savedObjects.cleanStandardList();
});
it('creates rule when settings are configured', async () => {
await supertest
.put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS)
.set('kbn-xsrf', 'true')
.send({
certExpirationThreshold: 30,
certAgeThreshold: 730,
defaultConnectors: testActions.slice(0, 2),
defaultEmail: { to: ['test@gmail.com'], cc: [], bcc: [] },
})
.expect(200);
const response = await supertest
.post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING)
.set('kbn-xsrf', 'true')
.send();
const statusResult = response.body.statusRule;
const tlsResult = response.body.tlsRule;
expect(statusResult.actions.length).eql(4);
expect(tlsResult.actions.length).eql(4);
compareRules(statusResult, statusRule);
compareRules(tlsResult, tlsRule);
testActions.slice(0, 2).forEach((action) => {
const { recoveredAction, firingAction } = getActionById(statusRule, action);
const resultAction = getActionById(statusResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
testActions.slice(0, 2).forEach((action) => {
const { recoveredAction, firingAction } = getActionById(tlsRule, action);
const resultAction = getActionById(tlsResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
});
it('updates rules when settings are updated', async () => {
await supertest
.put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS)
.set('kbn-xsrf', 'true')
.send({
certExpirationThreshold: 30,
certAgeThreshold: 730,
defaultConnectors: testActions,
defaultEmail: { to: ['test@gmail.com'], cc: [], bcc: [] },
})
.expect(200);
const response = await supertest
.put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING)
.set('kbn-xsrf', 'true')
.send();
const statusResult = response.body.statusRule;
const tlsResult = response.body.tlsRule;
expect(statusResult.actions.length).eql(9);
expect(tlsResult.actions.length).eql(9);
compareRules(statusResult, statusRule);
compareRules(tlsResult, tlsRule);
testActions.forEach((action) => {
const { recoveredAction, firingAction } = getActionById(statusRule, action);
const resultAction = getActionById(statusResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
testActions.forEach((action) => {
const { recoveredAction, firingAction } = getActionById(tlsRule, action);
const resultAction = getActionById(tlsResult, action);
expect(firingAction).eql(resultAction.firingAction);
expect(recoveredAction).eql(resultAction.recoveredAction);
});
});
});
}
const compareRules = (rule1: SanitizedRule, rule2: SanitizedRule) => {
expect(rule1.alertTypeId).eql(rule2.alertTypeId);
expect(rule1.schedule).eql(rule2.schedule);
};
const getActionById = (rule: SanitizedRule, id: string) => {
const actions = rule.actions.filter((action) => action.id === id);
const recoveredAction = actions.find(
(action) => 'group' in action && action.group === 'recovered'
);
const firingAction = actions.find((action) => 'group' in action && action.group !== 'recovered');
return {
recoveredAction: omit(recoveredAction, ['uuid']),
firingAction: omit(firingAction, ['uuid']),
};
};

View file

@ -0,0 +1,292 @@
/*
* 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 { StatusRuleParams } from '@kbn/synthetics-plugin/common/rules/status_rule';
import type { Client } from '@elastic/elasticsearch';
import { ToolingLog } from '@kbn/tooling-log';
import { makeDownSummary, makeUpSummary } from '@kbn/observability-synthetics-test-data';
import { RetryService } from '@kbn/ftr-common-functional-services';
import { EncryptedSyntheticsSavedMonitor } from '@kbn/synthetics-plugin/common/runtime_types';
import moment from 'moment';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { Agent as SuperTestAgent } from 'supertest';
import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants';
import expect from '@kbn/expect';
import { waitForAlertInIndex } from '../helpers/alerting_wait_for_helpers';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { PrivateLocationTestService } from './private_location_test_service';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
export const SYNTHETICS_ALERT_ACTION_INDEX = 'alert-action-synthetics';
export class SyntheticsRuleHelper {
supertest: SuperTestAgent;
logger: ToolingLog;
esClient: Client;
retryService: RetryService;
locService: PrivateLocationTestService;
alertActionIndex: string;
actionId: string | null = null;
constructor(getService: FtrProviderContext['getService']) {
this.esClient = getService('es');
this.supertest = getService('supertest');
this.logger = getService('log');
this.retryService = getService('retry');
this.locService = new PrivateLocationTestService(getService);
this.alertActionIndex = SYNTHETICS_ALERT_ACTION_INDEX;
}
async createIndexAction() {
await this.esClient.indices.create({
index: this.alertActionIndex,
body: {
mappings: {
properties: {
'monitor.id': {
type: 'keyword',
},
},
},
},
});
const actionId = await createIndexConnector({
supertest: this.supertest,
name: 'Index Connector: Synthetics API test',
indexName: this.alertActionIndex,
logger: this.logger,
});
this.actionId = actionId;
}
async createCustomStatusRule({
params,
name,
}: {
params: StatusRuleParams;
name?: string;
actions?: any[];
}) {
if (this.actionId === null) {
throw new Error('Index action not created. Call createIndexAction() first');
}
return await createRule<StatusRuleParams>({
params,
name: name ?? 'Custom status rule',
ruleTypeId: 'xpack.synthetics.alerts.monitorStatus',
consumer: 'alerts',
supertest: this.supertest,
esClient: this.esClient,
logger: this.logger,
schedule: { interval: '15s' },
actions: [
{
group: 'recovered',
id: this.actionId,
params: {
documents: [
{
status: 'recovered',
reason: '{{context.reason}}',
locationNames: '{{context.locationNames}}',
locationId: '{{context.locationId}}',
linkMessage: '{{context.linkMessage}}',
recoveryReason: '{{context.recoveryReason}}',
recoveryStatus: '{{context.recoveryStatus}}',
'monitor.id': '{{context.monitorId}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
{
group: 'xpack.synthetics.alerts.actionGroups.monitorStatus',
id: this.actionId,
params: {
documents: [
{
status: 'active',
reason: '{{context.reason}}',
locationNames: '{{context.locationNames}}',
locationId: '{{context.locationId}}',
linkMessage: '{{context.linkMessage}}',
'monitor.id': '{{context.monitorId}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
}
async addMonitor(name: string) {
const testData = {
locations: [
{ id: 'dev', isServiceManaged: true, label: 'Dev Service' },
{ id: 'dev2', isServiceManaged: true, label: 'Dev Service 2' },
],
name,
type: 'http',
url: 'http://www.google.com',
schedule: 1,
};
const res = await this.supertest
.post(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS)
.set('kbn-xsrf', 'true')
.send(testData);
expect(res.status).to.eql(200, JSON.stringify(res.body));
return res.body as EncryptedSyntheticsSavedMonitor;
}
async deleteMonitor(monitorId: string) {
const res = await this.supertest
.delete(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS + '/' + monitorId)
.set('kbn-xsrf', 'true')
.send();
expect(res.status).to.eql(200);
}
async updateTestMonitor(monitorId: string, updates: Record<string, any>) {
const result = await this.supertest
.put(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS + `/${monitorId}`)
.set('kbn-xsrf', 'true')
.send(updates);
expect(result.status).to.eql(200, JSON.stringify(result.body));
return result.body as EncryptedSyntheticsSavedMonitor;
}
async addPrivateLocation() {
await this.locService.installSyntheticsPackage();
return this.locService.addTestPrivateLocation();
}
async waitForStatusAlert({
ruleId,
filters,
}: {
ruleId: string;
filters?: QueryDslQueryContainer[];
}) {
return await waitForAlertInIndex({
ruleId,
filters,
esClient: this.esClient,
retryService: this.retryService,
logger: this.logger,
indexName: '.internal.alerts-observability.uptime.alerts-default*',
retryDelay: 1000,
});
}
async makeSummaries({
downChecks = 0,
upChecks = 0,
monitor,
location,
}: {
downChecks?: number;
upChecks?: number;
monitor: EncryptedSyntheticsSavedMonitor;
location?: {
id: string;
label: string;
};
}) {
const docs = [];
// lets make some down checks
for (let i = downChecks; i > 0; i--) {
const doc = await this.addSummaryDocument({
monitor,
location,
status: 'down',
timestamp: moment()
.subtract(i - 1, 'minutes')
.toISOString(),
});
docs.push(doc);
}
// lets make some up checks
for (let i = upChecks; i > 0; i--) {
const doc = await this.addSummaryDocument({
monitor,
location,
status: 'up',
timestamp: moment()
.subtract(i - 1, 'minutes')
.toISOString(),
});
docs.push(doc);
}
return docs;
}
async addSummaryDocument({
monitor,
location,
status = 'up',
timestamp = new Date(Date.now()).toISOString(),
}: {
monitor: EncryptedSyntheticsSavedMonitor;
status?: 'up' | 'down';
timestamp?: string;
location?: {
id: string;
label: string;
};
}) {
let document = {
'@timestamp': timestamp,
};
const index = 'synthetics-http-default';
const commonData = {
timestamp,
location,
monitorId: monitor.id,
name: monitor.name,
configId: monitor.config_id,
};
if (status === 'down') {
document = {
...makeDownSummary(commonData),
...document,
};
} else {
document = {
...makeUpSummary(commonData),
...document,
};
}
this.logger.debug(
`created synthetics summary, status: ${status}, monitor: "${monitor.name}", location: "${location?.label}"`
);
await this.esClient.index({
index,
document,
refresh: true,
});
return document;
}
}

View file

@ -10,7 +10,6 @@ import { v4 as uuidv4 } from 'uuid';
import {
ConfigKey,
HTTPFields,
LocationStatus,
PrivateLocation,
ServiceLocation,
} from '@kbn/synthetics-plugin/common/runtime_types';
@ -19,6 +18,7 @@ import { formatKibanaNamespace } from '@kbn/synthetics-plugin/common/formatters'
import { omit } from 'lodash';
import { PackagePolicy } from '@kbn/fleet-plugin/common';
import expect from '@kbn/expect';
import { getDevLocation } from '@kbn/synthetics-plugin/server/synthetics_service/get_service_locations';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getFixtureJson } from './helper/get_fixture_json';
import { comparePolicies, getTestSyntheticsPolicy } from './sample_data/test_policy';
@ -79,15 +79,7 @@ export default function ({ getService }: FtrProviderContext) {
const apiResponse = await supertestAPI.get(SYNTHETICS_API_URLS.SERVICE_LOCATIONS);
const testResponse: Array<PrivateLocation | ServiceLocation> = [
{
id: 'dev',
label: 'Dev Service',
geo: { lat: 0, lon: 0 },
url: 'mockDevUrl',
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
},
...getDevLocation('mockDevUrl'),
{
id: testFleetPolicyID,
isServiceManaged: false,

View file

@ -2036,7 +2036,7 @@ export default function ({ getService }: FtrProviderContext) {
failedMonitors: [
{
details:
"Invalid locations specified. Elastic managed Location(s) 'does not exist' not found. Available locations are 'dev'",
"Invalid locations specified. Elastic managed Location(s) 'does not exist' not found. Available locations are 'dev|dev2'",
id: httpProjectMonitors.monitors[1].id,
payload: {
'check.request': {

View file

@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) {
it('return error if invalid location specified', async () => {
const { message } = await addMonitorAPI({ type: 'http', locations: ['mars'] }, 400);
expect(message).eql(
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev'"
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev|dev2'"
);
});
@ -68,7 +68,7 @@ export default function ({ getService }: FtrProviderContext) {
400
);
expect(result.message).eql(
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev' Private Location(s) 'moon' not found. No private location available to use."
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev|dev2' Private Location(s) 'moon' not found. No private location available to use."
);
});

View file

@ -115,7 +115,7 @@ export default function ({ getService }: FtrProviderContext) {
400
);
expect(message).eql(
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev'"
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev|dev2'"
);
});
@ -141,7 +141,7 @@ export default function ({ getService }: FtrProviderContext) {
400
);
expect(result.message).eql(
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev' Private Location(s) 'moon' not found. No private location available to use."
"Invalid locations specified. Elastic managed Location(s) 'mars' not found. Available locations are 'dev|dev2' Private Location(s) 'moon' not found. No private location available to use."
);
});

View file

@ -154,13 +154,6 @@ export default function ({ getService }: FtrProviderContext) {
value: expect.any(String),
})),
]),
projects: [
{
count: 2,
label: project,
value: project,
},
],
monitorTypes: [
{
count: 20,
@ -173,6 +166,13 @@ export default function ({ getService }: FtrProviderContext) {
value: 'icmp',
},
],
projects: [
{
count: 2,
label: project,
value: project,
},
],
tags: expect.arrayContaining([
{
count: 21,
@ -242,6 +242,13 @@ export default function ({ getService }: FtrProviderContext) {
value: expect.any(String),
}))
),
monitorTypes: [
{
count: 2,
label: 'icmp',
value: 'icmp',
},
],
projects: [
{
count: 2,
@ -249,18 +256,6 @@ export default function ({ getService }: FtrProviderContext) {
value: project,
},
],
monitorTypes: [
// {
// count: 20,
// label: 'http',
// value: 'http',
// },
{
count: 2,
label: 'icmp',
value: 'icmp',
},
],
tags: expect.arrayContaining([
{
count: 1,

View file

@ -82,6 +82,15 @@ export default function ({ getService }: FtrProviderContext) {
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
},
{
id: 'dev2',
label: 'Dev Service 2',
geo: { lat: 0, lon: 0 },
url: 'mockDevUrl',
isServiceManaged: true,
status: LocationStatus.EXPERIMENTAL,
isInvalid: false,
},
{
id: testFleetPolicyID,
isInvalid: false,

View file

@ -168,6 +168,8 @@
"@kbn/reporting-server",
"@kbn/data-quality-plugin",
"@kbn/ml-trained-models-utils",
"@kbn/observability-synthetics-test-data",
"@kbn/ml-trained-models-utils",
"@kbn/openapi-common",
"@kbn/securitysolution-lists-common",
"@kbn/securitysolution-exceptions-common",
@ -181,6 +183,7 @@
"@kbn/management-settings-ids",
"@kbn/mock-idp-utils",
"@kbn/cloud-security-posture-common",
"@kbn/saved-objects-management-plugin"
"@kbn/saved-objects-management-plugin",
"@kbn/alerting-types"
]
}

View file

@ -5883,6 +5883,10 @@
version "0.0.0"
uid ""
"@kbn/observability-synthetics-test-data@link:x-pack/packages/observability/synthetics_test_data":
version "0.0.0"
uid ""
"@kbn/observability-utils@link:x-pack/packages/observability/observability_utils":
version "0.0.0"
uid ""