From 82d0b008cdc4f9bcfe3bc858b15d6d30e91fed89 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 1 Oct 2024 18:48:39 +0200 Subject: [PATCH] [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 image ### From Locations threshold Will be considered down only when from defined number of locations image ### Over time with checks threshold just like uptime custom status alert image --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dominique Clarke Co-authored-by: Elastic Machine Co-authored-by: Maryam Saeidi Co-authored-by: Justin Kambic --- .github/CODEOWNERS | 1 + package.json | 1 + .../generated/observability_uptime_schema.ts | 8 +- tsconfig.base.json | 2 + .../synthetics_test_data/README.md | 3 + .../synthetics_test_data/index.ts | 8 + .../synthetics_test_data/jest.config.js | 12 + .../synthetics_test_data/kibana.jsonc | 5 + .../synthetics_test_data/package.json | 8 + .../src/make_summaries.ts} | 284 ++--- .../synthetics_test_data/src/utils.ts | 16 + .../synthetics_test_data/tsconfig.json | 20 + .../alert_as_data_fields.test.ts.snap | 60 + .../common/constants/client_defaults.ts | 52 +- .../synthetics/common/field_names.ts | 1 + .../common/rules/alert_actions.test.ts | 19 +- .../common/rules/status_rule.test.ts | 38 + .../synthetics/common/rules/status_rule.ts | 97 +- .../common/rules/synthetics/translations.ts | 27 +- .../common/rules/synthetics_rule_field_map.ts | 12 + .../runtime_types/alert_rules/common.ts | 18 +- .../common/runtime_types/ping/ping.ts | 9 +- .../custom_status_alert.journey.ts | 78 ++ .../default_status_alert.journey.ts | 57 +- .../e2e/synthetics/journeys/index.ts | 3 +- .../journeys/services/data/browser_docs.ts | 30 +- .../journeys/services/synthetics_services.ts | 77 +- .../synthetics/e2e/tsconfig.json | 3 +- .../common/condition_locations_value.tsx | 59 + .../alerts/common/condition_window_value.tsx | 107 ++ .../alerts/common/field_filters.tsx | 124 ++ .../common/field_popover_expression.tsx | 74 ++ .../alerts/common/field_selector.test.tsx | 70 ++ .../alerts/common/field_selector.tsx | 110 ++ .../components/alerts/common/fields.tsx | 159 +++ .../alerts/common/for_the_last_expression.tsx | 172 +++ .../alerts/common/group_by_field.tsx | 55 + .../alerts/common/popover_expression.tsx | 41 + .../components/alerts/hooks/translations.ts | 14 +- .../hooks/use_fetch_synthetics_suggestions.ts | 66 ++ .../alerts/hooks/use_synthetics_rules.ts | 49 +- .../components/alerts/query_bar.tsx | 77 ++ .../alerts/rule_name_with_loading.tsx | 28 + .../alerts/status_rule_expression.tsx | 157 +++ .../components/alerts/status_rule_ui.tsx | 39 + .../alerts/toggle_alert_flyout_button.tsx | 163 ++- .../overview/monitor_detail_flyout.test.tsx | 4 + .../overview/overview/overview_status.tsx | 4 +- .../lazy_wrapper/monitor_status.tsx | 23 +- .../lib/alert_types/monitor_status.tsx | 2 +- .../synthetics/state/monitor_list/effects.ts | 10 +- .../synthetics/state/overview_status/index.ts | 27 +- .../apps/synthetics/state/ui/actions.ts | 7 +- .../public/apps/synthetics/state/ui/index.ts | 9 +- .../apps/synthetics/state/ui/selectors.ts | 7 +- .../__mocks__/synthetics_store.mock.ts | 2 +- .../synthetics/scripts/generate_monitors.js | 9 + .../scripts/tasks/generate_monitors.ts | 97 ++ .../server/alert_rules/common.test.ts | 331 +++++- .../synthetics/server/alert_rules/common.ts | 472 +++++--- .../alert_rules/status_rule/message_utils.ts | 209 +++- .../status_rule/monitor_status_rule.ts | 122 +- .../status_rule/queries/filter_monitors.ts | 104 ++ .../query_monitor_status_alert.ts | 159 ++- .../status_rule/status_rule_executor.test.ts | 776 ++++++++++--- .../status_rule/status_rule_executor.ts | 459 ++++++-- .../server/alert_rules/status_rule/types.ts | 52 +- .../server/alert_rules/status_rule/utils.ts | 36 + .../server/alert_rules/translations.ts | 9 + .../synthetics/server/lib.ts | 7 +- .../synthetics/server/routes/common.test.ts | 35 + .../synthetics/server/routes/common.ts | 49 +- .../get_service_locations.ts | 31 +- .../synthetics_service.test.ts | 12 + .../synthetics/tsconfig.json | 4 + .../common/rules/uptime_rule_field_map.ts | 12 + .../lib/alerts/status_check.test.ts | 5 +- .../legacy_uptime/lib/alerts/status_check.ts | 4 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - .../common/expression_items/for_the_last.tsx | 19 +- .../public/common/expression_items/value.tsx | 6 +- .../alerting_api_integration/common/config.ts | 4 + .../helpers/alerting_api_helper.ts | 8 +- .../helpers/alerting_wait_for_helpers.ts | 31 +- .../observability/index.ts | 3 +- .../synthetics/custom_status_rule.ts | 1000 +++++++++++++++++ .../data.ts} | 162 +-- .../private_location_test_service.ts | 83 ++ .../synthetics/synthetics_default_rule.ts | 131 +++ .../synthetics/synthetics_rule_helper.ts | 292 +++++ .../add_monitor_private_location.ts | 12 +- .../apis/synthetics/add_monitor_project.ts | 2 +- .../apis/synthetics/add_monitor_public_api.ts | 4 +- .../synthetics/edit_monitor_public_api.ts | 4 +- .../apis/synthetics/suggestions.ts | 31 +- .../apis/synthetics/sync_global_params.ts | 9 + x-pack/test/tsconfig.json | 5 +- yarn.lock | 4 + 100 files changed, 6139 insertions(+), 1292 deletions(-) create mode 100644 x-pack/packages/observability/synthetics_test_data/README.md create mode 100644 x-pack/packages/observability/synthetics_test_data/index.ts create mode 100644 x-pack/packages/observability/synthetics_test_data/jest.config.js create mode 100644 x-pack/packages/observability/synthetics_test_data/kibana.jsonc create mode 100644 x-pack/packages/observability/synthetics_test_data/package.json rename x-pack/{plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/sample_docs.ts => packages/observability/synthetics_test_data/src/make_summaries.ts} (53%) create mode 100644 x-pack/packages/observability/synthetics_test_data/src/utils.ts create mode 100644 x-pack/packages/observability/synthetics_test_data/tsconfig.json create mode 100644 x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.test.ts create mode 100644 x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/custom_status_alert.journey.ts create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_locations_value.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_window_value.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_filters.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_popover_expression.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.test.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/fields.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/for_the_last_expression.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/group_by_field.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/popover_expression.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_fetch_synthetics_suggestions.ts create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/rule_name_with_loading.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_expression.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_ui.tsx create mode 100644 x-pack/plugins/observability_solution/synthetics/scripts/generate_monitors.js create mode 100644 x-pack/plugins/observability_solution/synthetics/scripts/tasks/generate_monitors.ts create mode 100644 x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/filter_monitors.ts rename x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/{ => queries}/query_monitor_status_alert.ts (51%) create mode 100644 x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/utils.ts create mode 100644 x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts rename x-pack/test/alerting_api_integration/observability/{synthetics_rule.ts => synthetics/data.ts} (59%) create mode 100644 x-pack/test/alerting_api_integration/observability/synthetics/private_location_test_service.ts create mode 100644 x-pack/test/alerting_api_integration/observability/synthetics/synthetics_default_rule.ts create mode 100644 x-pack/test/alerting_api_integration/observability/synthetics/synthetics_rule_helper.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 35d8fa1caedab..b3af3edce4585 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index 5446bea2c12b2..1a372d63dfcf0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts index db6844688dbc6..6a746f722d612 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts @@ -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, diff --git a/tsconfig.base.json b/tsconfig.base.json index 7a66911a4bee5..c0a3aed9ff3ac 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/x-pack/packages/observability/synthetics_test_data/README.md b/x-pack/packages/observability/synthetics_test_data/README.md new file mode 100644 index 0000000000000..922df9939572d --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/README.md @@ -0,0 +1,3 @@ +# @kbn/observability-synthetics-test-data + +Provides utilities to generate synthetics test data diff --git a/x-pack/packages/observability/synthetics_test_data/index.ts b/x-pack/packages/observability/synthetics_test_data/index.ts new file mode 100644 index 0000000000000..d1fe1034d7b1e --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/index.ts @@ -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'; diff --git a/x-pack/packages/observability/synthetics_test_data/jest.config.js b/x-pack/packages/observability/synthetics_test_data/jest.config.js new file mode 100644 index 0000000000000..62001f4072246 --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/jest.config.js @@ -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: ['/x-pack/packages/observability/synthetics_test_data'], +}; diff --git a/x-pack/packages/observability/synthetics_test_data/kibana.jsonc b/x-pack/packages/observability/synthetics_test_data/kibana.jsonc new file mode 100644 index 0000000000000..94f80d9b59cad --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/observability-synthetics-test-data", + "owner": "@elastic/obs-ux-management-team", +} diff --git a/x-pack/packages/observability/synthetics_test_data/package.json b/x-pack/packages/observability/synthetics_test_data/package.json new file mode 100644 index 0000000000000..8ee7793e644ca --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/package.json @@ -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" +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/sample_docs.ts b/x-pack/packages/observability/synthetics_test_data/src/make_summaries.ts similarity index 53% rename from x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/sample_docs.ts rename to x-pack/packages/observability/synthetics_test_data/src/make_summaries.ts index 62e2013ce0ff8..a44ffc15c28ec 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/sample_docs.ts +++ b/x-pack/packages/observability/synthetics_test_data/src/make_summaries.ts @@ -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 = ({ }, }, }, -}); +}; diff --git a/x-pack/packages/observability/synthetics_test_data/src/utils.ts b/x-pack/packages/observability/synthetics_test_data/src/utils.ts new file mode 100644 index 0000000000000..cf35a93491d54 --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/src/utils.ts @@ -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', + }, +}); diff --git a/x-pack/packages/observability/synthetics_test_data/tsconfig.json b/x-pack/packages/observability/synthetics_test_data/tsconfig.json new file mode 100644 index 0000000000000..86d57b8d692f7 --- /dev/null +++ b/x-pack/packages/observability/synthetics_test_data/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + ] +} diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap index 2ce82a1b3925f..478957afb1d66 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap @@ -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", }, diff --git a/x-pack/plugins/observability_solution/synthetics/common/constants/client_defaults.ts b/x-pack/plugins/observability_solution/synthetics/common/constants/client_defaults.ts index 07d17554e83fc..f1098c89b7caa 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/constants/client_defaults.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/constants/client_defaults.ts @@ -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 = ({ diff --git a/x-pack/plugins/observability_solution/synthetics/common/field_names.ts b/x-pack/plugins/observability_solution/synthetics/common/field_names.ts index 0407fad341d8a..7b590b3a828a7 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/field_names.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/field_names.ts @@ -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'; diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts index 345342d1f4c62..dd7ad03b9ac32 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts @@ -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'], }, }, diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.test.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.test.ts new file mode 100644 index 0000000000000..67292f67283ad --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.ts index 375e0c0dd08c1..584888353cbc4 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/status_rule.ts @@ -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; export type StatusRuleParams = TypeOf; +export type StatusRuleCondition = TypeOf; + +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), + }; +}; diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics/translations.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics/translations.ts index 84a02ac9b7f92..0161b55273a01 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics/translations.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics/translations.ts @@ -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', { diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts index 97ed491b320c9..79d83359132dc 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts @@ -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 diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/alert_rules/common.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/alert_rules/common.ts index 790ce35264752..6a07615ac1644 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/alert_rules/common.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/alert_rules/common.ts @@ -25,14 +25,7 @@ export const SyntheticsCommonStateCodec = t.intersection([ export type SyntheticsCommonState = t.TypeOf; -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; export type StaleDownConfig = t.TypeOf; export type AlertStatusMetaData = t.TypeOf; export type AlertOverviewStatus = t.TypeOf; +export type AlertStatusConfigs = Record; diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/ping/ping.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/ping/ping.ts index cf06fb899c948..073a00e5665df 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/ping/ping.ts @@ -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, diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/custom_status_alert.journey.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/custom_status_alert.journey.ts new file mode 100644 index 0000000000000..161a58d650e6c --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/custom_status_alert.journey.ts @@ -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, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/default_status_alert.journey.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/default_status_alert.journey.ts index 4807255cc28ee..e2285d499a0f2 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/default_status_alert.journey.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/alert_rules/default_status_alert.journey.ts @@ -29,20 +29,27 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => { before(async () => { await services.cleaUp(); - await services.enableMonitorManagedViaApi(); + }); + + 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', - 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(); + await services.addTestSummaryDocument({ + timestamp: firstCheckTime, + configId, + }); }); step('Go to monitors page', async () => { @@ -50,21 +57,22 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => { }); 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')); }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/index.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/index.ts index a35e62ec4fe6a..1e2b17b34e096 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/index.ts @@ -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'; diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/browser_docs.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/browser_docs.ts index d3964692a38f0..af5c66595cda8 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/browser_docs.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/data/browser_docs.ts @@ -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', diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts index 028f8a736e93c..23c5ef45d1383 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts @@ -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; + } } diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/tsconfig.json b/x-pack/plugins/observability_solution/synthetics/e2e/tsconfig.json index bbc7edf10c1f6..7584c000a76fa 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/tsconfig.json +++ b/x-pack/plugins/observability_solution/synthetics/e2e/tsconfig.json @@ -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" ] } diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_locations_value.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_locations_value.tsx new file mode 100644 index 0000000000000..7b5babfd38786 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_locations_value.tsx @@ -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 ( + + + {i18n.translate('xpack.synthetics.windowValueExpression.numberOfLocPopoverTitleLabel', { + defaultMessage: 'Number of locations', + })} + + onLocationCountChange(Number(evt.target.value))} + /> + + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_window_value.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_window_value.tsx new file mode 100644 index 0000000000000..717a15f9da4f2 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/condition_window_value.tsx @@ -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 ( + + + {i18n.translate( + 'xpack.synthetics.windowValueExpression.numberOfChecksPopoverTitleLabel', + { defaultMessage: 'Number of checks' } + )} + + onNumberOfChecksChange(Number(evt.target.value))} + /> + + ); + } + + return ( + { + onTimeWindowChange({ size: val ?? 5, unit: timeWindowUnit }); + }} + onChangeWindowUnit={(val) => { + onTimeWindowChange({ size: timeWindowSize, unit: (val ?? 'm') as TIME_UNITS }); + }} + errors={{}} + description="" + /> + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_filters.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_filters.tsx new file mode 100644 index 0000000000000..31bf9e45ed8f3 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_filters.tsx @@ -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(''); + const [selectedField, setSelectedField] = useState(); + + const { + suggestions = [], + isLoading, + allSuggestions, + } = useFetchSyntheticsSuggestions({ + search, + fieldName: selectedField, + }); + + const onFieldChange = useCallback( + (key: FieldKeys, value?: string[]) => { + setRuleParams(key, value); + }, + [setRuleParams] + ); + + return ( + <> + + + { + onFieldChange('monitorIds', val); + }} + value={ruleParams.monitorIds} + setSearch={setSearch} + suggestions={suggestions} + allSuggestions={allSuggestions} + isLoading={isLoading} + setSelectedField={setSelectedField} + selectedField={selectedField} + /> + + + { + onFieldChange('monitorTypes', val); + }} + value={ruleParams.monitorTypes} + setSearch={setSearch} + suggestions={suggestions} + allSuggestions={allSuggestions} + isLoading={isLoading} + setSelectedField={setSelectedField} + selectedField={selectedField} + /> + + + + + + { + onFieldChange('tags', val); + }} + value={ruleParams.tags} + setSearch={setSearch} + suggestions={suggestions} + allSuggestions={allSuggestions} + isLoading={isLoading} + setSelectedField={setSelectedField} + selectedField={selectedField} + /> + + + { + onFieldChange('projects', val); + }} + value={ruleParams.projects} + setSearch={setSearch} + suggestions={suggestions} + allSuggestions={allSuggestions} + isLoading={isLoading} + setSelectedField={setSelectedField} + selectedField={selectedField} + /> + + + + + + { + onFieldChange('locations', val); + }} + value={ruleParams.locations} + setSearch={setSearch} + suggestions={suggestions} + allSuggestions={allSuggestions} + isLoading={isLoading} + setSelectedField={setSelectedField} + selectedField={selectedField} + /> + + + + + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_popover_expression.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_popover_expression.tsx new file mode 100644 index 0000000000000..c5927e6c0e6b9 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_popover_expression.tsx @@ -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; +} + +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 ( + + + } + repositionOnScroll + > +
{children}
+
+
+ ); +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.test.tsx new file mode 100644 index 0000000000000..0255b0014a1f0 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.test.tsx @@ -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> = [ + { 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> = [ + { 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> = []; + + onFieldChange(selected, onChangeMock); + + expect(onChangeMock).toHaveBeenCalledWith([]); + }); + + it('should call onChange with the filtered array when no ALL_VALUE is present', () => { + const selected: Array> = [ + { 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> = [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: ALL_VALUE }, + ]; + + onFieldChange(selected, onChangeMock); + + expect(onChangeMock).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.tsx new file mode 100644 index 0000000000000..96b44e7e5dce4 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/field_selector.tsx @@ -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 ( + + >) => { + 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`, + }; + })} + /> + + ); +} + +export const onFieldChange = ( + selected: Array>, + 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)); +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/fields.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/fields.tsx new file mode 100644 index 0000000000000..2c2e9714d998e --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/fields.tsx @@ -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; + 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 ( + + + + ); +} + +export function TagsField({ value, onChange, ...rest }: FieldProps) { + return ( + + + + ); +} + +export function MonitorTypeField({ value, onChange, ...rest }: FieldProps) { + const label = i18n.translate('xpack.synthetics.alerting.fields.type', { + defaultMessage: 'Type', + }); + return ( + + + + ); +} + +export function LocationsField({ value, onChange, ...rest }: FieldProps) { + const label = i18n.translate('xpack.synthetics.alerting.fields.location', { + defaultMessage: 'Locations', + }); + return ( + + + + ); +} + +export function ProjectsField({ value, onChange, ...rest }: FieldProps) { + const label = i18n.translate('xpack.synthetics.alerting.fields.project', { + defaultMessage: 'Projects', + }); + return ( + + + + ); +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/for_the_last_expression.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/for_the_last_expression.tsx new file mode 100644 index 0000000000000..81153d88be61d --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/for_the_last_expression.tsx @@ -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(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 ( + setIsOpen(!isOpen)} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="downLeft" + > + + 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) => ( +
+ + {i18n.translate('xpack.synthetics.forTheLastExpression.whenPopoverTitleLabel', { + defaultMessage: 'When', + })} + + {list} +
+ )} + +
+ ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/group_by_field.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/group_by_field.tsx new file mode 100644 index 0000000000000..92cc2abe3517c --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/group_by_field.tsx @@ -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 ( + + + onChange(e.target.checked)} + /> + + + {disabledGroupBy ? ( + + ) : ( + + )} + + + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/popover_expression.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/popover_expression.tsx new file mode 100644 index 0000000000000..3841f25ac2a8b --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/common/popover_expression.tsx @@ -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 ( + setPopoverOpen(false)} + button={ + setPopoverOpen((state) => !state)} + /> + } + repositionOnScroll + > + {children} + + ); +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts index 2901b67820485..eb54c108b5de3 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_fetch_synthetics_suggestions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_fetch_synthetics_suggestions.ts new file mode 100644 index 0000000000000..a5f16ffab8b7a --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_fetch_synthetics_suggestions.ts @@ -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; +} + +export interface Params { + fieldName?: string; + filters?: { + locations?: string[]; + monitorIds?: string[]; + tags?: string[]; + projects?: string[]; + }; + search: string; +} + +type ApiResponse = Record; + +export function useFetchSyntheticsSuggestions({ + filters, + fieldName, + search, +}: Params): UseFetchSyntheticsSuggestions { + const { http } = useKibana().services; + const { locations, monitorIds, tags, projects } = filters || {}; + + const { loading, data } = useFetcher( + async ({ signal }) => { + return await http.get('/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), + }; +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts index 6e03b69b5d60c..5ffb17b639768 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts @@ -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().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] + ); }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx new file mode 100644 index 0000000000000..11d17abb6c81a --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx @@ -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().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 ( + + { + onChange({ + kqlQuery: String(queryN.query), + }); + }} + onSubmit={(queryN) => { + if (queryN) { + onChange({ + kqlQuery: String(queryN.query), + }); + } + }} + query={{ query: String(kqlQuery), language: 'kuery' }} + autoSubmit={true} + disableLanguageSwitcher={true} + /> + + ); +} + +const PLACEHOLDER = i18n.translate('xpack.synthetics.list.search', { + defaultMessage: 'Filter by KQL query', +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/rule_name_with_loading.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/rule_name_with_loading.tsx new file mode 100644 index 0000000000000..4f4b8b5d60ddf --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/rule_name_with_loading.tsx @@ -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 ( + + {ruleName} + {isLoading && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_expression.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_expression.tsx new file mode 100644 index 0000000000000..b3a701d474802 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_expression.tsx @@ -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 = ({ 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 ( + <> + + + + +

+ {i18n.translate('xpack.synthetics.rules.status.condition.title', { + defaultMessage: 'Condition', + })} +

+
+
+ + + +
+ + + + + + + { + onThresholdChange(val); + }} + description={StatusTranslations.isDownDescription} + errors={[]} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +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', + } + ), +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_ui.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_ui.tsx new file mode 100644 index 0000000000000..70278c8951773 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/status_rule_ui.tsx @@ -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; + +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 ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx index 0a8e5abf37f1a..6203652578480 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx @@ -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().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: ( - - {ToggleFlyoutTranslations.toggleMonitorStatusContent} - {loading && ( - - - - )} - - ), - 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: ( - - {ToggleFlyoutTranslations.toggleTlsContent} - {loading && ( - - - - )} - - ), - 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: , - 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: , + 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: , + 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: , + 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 = () => { setIsOpen(!isOpen)} @@ -130,6 +144,7 @@ export const ToggleAlertFlyoutButton = () => { {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', + } +); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx index a575ecc110eb4..49098f8de0225 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx @@ -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', diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx index 4f089b2464ed9..88133c5c06d38 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx @@ -97,7 +97,7 @@ export function OverviewStatus({ titleAppend }: { titleAppend?: React.ReactNode - - - + {params.id && isEmpty(ruleParams) && ( + + + + )} + + {(!params.id || !isEmpty(ruleParams)) && ( + + )} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx index 5f63d6ac298c7..794747853642c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx @@ -41,7 +41,7 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({ }, defaultActionMessage, defaultRecoveryMessage, - requiresAppContext: true, + requiresAppContext: false, format: ({ fields }) => { return { reason: fields[ALERT_REASON] || '', diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/effects.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/effects.ts index bbec528db6de2..f1c949ccf2b31 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/effects.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/effects.ts @@ -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 ) { diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview_status/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview_status/index.ts index 6cdadd03d15bd..2670aa913d61a 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview_status/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview_status/index.ts @@ -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; }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts index 06b9506ead191..7a9e3e2884b9a 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts @@ -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('[UI] SET BASE PATH'); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts index 2c7d5e5ce3d4c..f1314bbae4fa0 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts @@ -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; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts index f02b1fb564c37..92e5d249a583d 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts @@ -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 ); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index aa1743b7f27db..fe2ad5f7512cc 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -24,7 +24,7 @@ import { MonitorDetailsState } from '../../../state'; */ export const mockState: SyntheticsAppState = { ui: { - alertFlyoutVisible: null, + ruleFlyoutVisible: null, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/plugins/observability_solution/synthetics/scripts/generate_monitors.js b/x-pack/plugins/observability_solution/synthetics/scripts/generate_monitors.js new file mode 100644 index 0000000000000..c18274f04f5b2 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/scripts/generate_monitors.js @@ -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(); diff --git a/x-pack/plugins/observability_solution/synthetics/scripts/tasks/generate_monitors.ts b/x-pack/plugins/observability_solution/synthetics/scripts/tasks/generate_monitors.ts new file mode 100644 index 0000000000000..4e571344ce870 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/scripts/tasks/generate_monitors.ts @@ -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'], + }; +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts index 787d35c99b675..34f3be6128a3f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts @@ -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; @@ -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 = { + 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 = { + 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 = { + 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 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. Checked at February 25, 2023 7:00 PM.', - stateId: null, + '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', }, }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts index 18ade57662ed3..c1bf18e18e90b 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts @@ -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 >; 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; - - if (recoveredAlertId && locationId && staleDownConfigs[recoveredAlertId]) { - const downConfig = staleDownConfigs[recoveredAlertId]; - const { ping } = downConfig; - monitorSummary = getMonitorSummary( - ping, - RECOVERED_LABEL, - locationId, - downConfig.configId, + 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 (!groupByLocation && monitorSummary) { + const formattedLocationNames = locationName.join(` ${AND_LABEL} `); + const formattedLocationIds = locationIds.join(` ${AND_LABEL} `); + monitorSummary.locationNames = formattedLocationNames; + monitorSummary.locationName = formattedLocationNames; + monitorSummary.locationId = formattedLocationIds; + } + + if (recoveredAlertId && locationIds && staleDownConfigs[recoveredAlertId]) { + const summary = getDeletedMonitorOrLocationSummary({ + staleDownConfigs, + recoveredAlertId, + locationIds, 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`, - } - ); + tz, + params, + }); + 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 (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, + if (configId && recoveredAlertId && locationIds && upConfigs[recoveredAlertId]) { + const summary = getUpMonitorRecoverySummary({ + upConfigs, + recoveredAlertId, + alertHit, + locationIds, configId, + basePath, + spaceId, 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 (basePath && spaceId && stateId) { - const relativeViewInAppUrl = getRelativeViewInAppUrl({ - configId, - locationId, - stateId, - }); - linkMessage = getFullViewInAppMessage(basePath, spaceId, relativeViewInAppUrl); + 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 + >; + 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 = { +export const SyntheticsRuleTypeAlertDefinition: IRuleTypeAlerts = { 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', +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts index 22b15b5cdefd0..a0a14ddaebfed 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts @@ -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 = { 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'); + 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, + 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, + }, + }) + : '', + }, + }); +}; - return i18n.translate('xpack.synthetics.alertRules.monitorStatus.reasonMessage', { - defaultMessage: `Monitor "{name}" from {location} is {status}. Checked at {checkedAt}.`, +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, - checkedAt, + downThreshold, + unitLabel: getTimeUnitLabel(timeWindow), + locationsThreshold, + size: timeWindow.size, }, }); }; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/monitor_status_rule.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/monitor_status_rule.ts index a5d530f2ec53b..ff4516a861225 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/monitor_status_rule.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/monitor_status_rule.ts @@ -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; -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 { diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/filter_monitors.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/filter_monitors.ts new file mode 100644 index 0000000000000..c850c2b6d6d30 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/filter_monitors.ts @@ -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; +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/query_monitor_status_alert.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/query_monitor_status_alert.ts similarity index 51% rename from x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/query_monitor_status_alert.ts rename to x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/query_monitor_status_alert.ts index 9c8e2fa1fa21b..965c30d9c33ae 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/query_monitor_status_alert.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/queries/query_monitor_status_alert.ts @@ -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; -export async function queryMonitorStatusForAlert( - esClient: SyntheticsEsClient, - monitorLocationIds: string[], - range: { from: string; to: string }, - monitorQueryIds: string[], - monitorLocationsMap: Record, - monitorQueryIdToConfigIdMap: Record -): Promise { +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; + numberOfChecks: number; + includeRetests?: boolean; +}): Promise { 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 = {}; - const downConfigs: Record = {}; - const monitorsWithoutData = new Map(Object.entries(cloneDeep(monitorLocationsMap))); - const pendingConfigs: Record = {}; + 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( - params, - 'getCurrentStatusOverview' + i - ); + const { body: result } = await esClient.search(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: {}, }; } diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.test.ts index 02373c34ac6a5..76c05d6fa2930 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.test.ts @@ -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({}); - - expect(downConfigs).toEqual({}); - expect(staleDownConfigs).toEqual({}); - - expect(spy).toHaveBeenCalledWith({ - filter: 'synthetics-monitor.attributes.alert.status.enabled: true', - soClient, + const mockStart = coreMock.createStart(); + const uiSettingsClient = mockStart.uiSettings.asScopedToClient(soClient); + + const statusRule = new StatusRuleExecutor(serverMock, monitorClient, { + params: {}, + services: { + uiSettingsClient, + savedObjectsClient: soClient, + scopedClusterClient: { asCurrentUser: mockEsClient }, + }, + rule: { + name: 'test', + }, + } as any); + + describe('DefaultRule', () => { + it('should only query enabled monitors', async () => { + const spy = jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([]); + + const { downConfigs, staleDownConfigs } = await statusRule.getDownChecks({}); + + expect(downConfigs).toEqual({}); + expect(staleDownConfigs).toEqual({}); + + expect(spy).toHaveBeenCalledWith({ + filter: 'synthetics-monitor.attributes.alert.status.enabled: true', + soClient, + }); }); - }); - 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 } = 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, - }, + it('marks deleted configs as expected', async () => { + jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue(testMonitors); + + 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, + }, + }, + }); }); - 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, + }, + }, + }); }); }); - 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', + 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, }, - ], + }, }, - }, - ]); - const statusRule = new StatusRuleExecutor( - moment().toDate(), - {}, - soClient, - mockEsClient, - serverMock, - monitorClient - ); - - 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(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, + }); }); - 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('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; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts index 691176fad6e74..7dcea3a6084f0 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts @@ -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> = []; + hasCustomCondition: boolean; + monitorLocationsMap: Record; // 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() { + const baseFilter = !this.hasCustomCondition + ? `${monitorAttributes}.${AlertConfigKey.STATUS_ENABLED}: true` + : ''; + + const configIds = await queryFilterMonitors({ + spaceId: this.options.spaceId, + esClient: this.esClient, + ruleParams: this.params, + }); + + 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: `${monitorAttributes}.${AlertConfigKey.STATUS_ENABLED}: true`, + filter: filtersStr, }); - const { - allIds, - enabledMonitorQueryIds, - monitorLocationIds, - monitorLocationsMap, - projectMonitorsCount, - monitorQueryIdToConfigIdMap, - } = processMonitors(this.monitors); - - return { - enabledMonitorQueryIds, - monitorLocationIds, - allIds, - monitorLocationsMap, - projectMonitorsCount, - monitorQueryIdToConfigIdMap, - }; + this.debug(`Found ${this.monitors.length} monitors for params ${JSON.stringify(this.params)}`); + return processMonitors(this.monitors); } - async getDownChecks( - prevDownConfigs: AlertOverviewStatus['downConfigs'] = {} - ): Promise { - const { - monitorLocationIds, - enabledMonitorQueryIds, - monitorLocationsMap, - monitorQueryIdToConfigIdMap, - } = await this.getMonitors(); - const from = this.previousStartedAt - ? moment(this.previousStartedAt).subtract(1, 'minute').toISOString() - : 'now-2m'; + async getDownChecks(prevDownConfigs: AlertStatusConfigs = {}): Promise { + await this.init(); + const { enabledMonitorQueryIds, maxPeriod, monitorLocationIds, monitorLocationsMap } = + await this.getMonitors(); - if (enabledMonitorQueryIds.length > 0) { - const currentStatus = await queryMonitorStatusForAlert( - this.esClient, - monitorLocationIds, - { - to: 'now', - from, - }, - enabledMonitorQueryIds, - monitorLocationsMap, - monitorQueryIdToConfigIdMap - ); + const range = this.getRange(maxPeriod); - 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); + const { numberOfChecks } = getConditionType(this.params.condition); + if (enabledMonitorQueryIds.length === 0) { + const staleDownConfigs = this.markDeletedConfigs(prevDownConfigs); return { - ...currentStatus, + downConfigs: { ...prevDownConfigs }, + upConfigs: {}, staleDownConfigs, + enabledMonitorQueryIds, + pendingConfigs: {}, }; } - const staleDownConfigs = this.markDeletedConfigs(prevDownConfigs); + + 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, + 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 { - downConfigs: { ...prevDownConfigs }, - upConfigs: {}, - pendingConfigs: {}, + ...currentStatus, staleDownConfigs, - down: 0, - up: 0, - pending: 0, - enabledMonitorQueryIds, + pendingConfigs: {}, }; } - markDeletedConfigs(downConfigs: AlertOverviewStatus['downConfigs']) { + getRange = (maxPeriod: number) => { + let from = this.previousStartedAt + ? moment(this.previousStartedAt).subtract(1, 'minute').toISOString() + : 'now-2m'; + + 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; + + from = moment().subtract(size, unit).toISOString(); + } + + this.debug( + `Using range from ${from} to now, diff of ${moment().diff(from, 'minutes')} minutes` + ); + + return { from, to: 'now' }; + }; + + markDeletedConfigs(downConfigs: AlertStatusConfigs): Record { 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 => { + const downConfigsById = new Map(); + Object.entries(downConfigs).forEach(([_, config]) => { + const { configId } = config; + if (!downConfigsById.has(configId)) { + downConfigsById.set(configId, []); + } + downConfigsById.get(configId)?.push(config); + }); + return downConfigsById; +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts index 16f8318b04f2e..3190881d728c4 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts @@ -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; +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; } diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/utils.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/utils.ts new file mode 100644 index 0000000000000..608526c3b39e3 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/utils.ts @@ -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'); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts index 5dced6f8928e5..37a4cd1e12e97 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts @@ -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( diff --git a/x-pack/plugins/observability_solution/synthetics/server/lib.ts b/x-pack/plugins/observability_solution/synthetics/server/lib.ts index 22f5661ca532f..94150c0fb8ee5 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/lib.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/lib.ts @@ -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; } diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/common.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/common.test.ts index 82520c68b5fc4..7e5c0d610e520 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/common.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/common.test.ts @@ -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(''); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/common.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/common.ts index bc58a866bef83..2edb17a77a635 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/common.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/common.ts @@ -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 = ''; diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_service_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_service_locations.ts index ca45e74f89004..0f6b398fb6b68 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_service_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_service_locations.ts @@ -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; diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts index 40f392084942b..fdc41831e8afd 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts @@ -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', + }, ]); }); diff --git a/x-pack/plugins/observability_solution/synthetics/tsconfig.json b/x-pack/plugins/observability_solution/synthetics/tsconfig.json index d0822a733baff..24411ebdcb0c5 100644 --- a/x-pack/plugins/observability_solution/synthetics/tsconfig.json +++ b/x-pack/plugins/observability_solution/synthetics/tsconfig.json @@ -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" diff --git a/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts b/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts index ca6463416de6b..3b2da879c5288 100644 --- a/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts +++ b/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts @@ -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 diff --git a/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.test.ts b/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.test.ts index 13d959000c3a8..6054866c7b608 100644 --- a/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.test.ts @@ -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 = ( diff --git a/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.ts b/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.ts index ea6edf35b6f12..0ce64bc803821 100644 --- a/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.ts +++ b/x-pack/plugins/observability_solution/uptime/server/legacy_uptime/lib/alerts/status_check.ts @@ -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, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d1d4e79320a21..c07189b1669d8 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 75cf864832ceb..bc63fd4be919f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "まもなく有効期限切れです", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d7f138cdc84a2..10a7f0e566fef 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "即将到期", diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx index 6814fa11ddcae..808c9f8b1bae4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx @@ -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 = ({ 0} + isInvalid={Number(errors.timeWindowSize?.length) > 0} error={errors.timeWindowSize as string[]} > 0} + isInvalid={Number(errors.timeWindowSize?.length) > 0} min={0} value={timeWindowSize || ''} onChange={(e) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/value.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/value.tsx index 63c516dfea57e..5080b902c7775 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/value.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/value.tsx @@ -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 = ({ { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 1faadc6041634..943135565428f 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -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', ], }, }; diff --git a/x-pack/test/alerting_api_integration/observability/helpers/alerting_api_helper.ts b/x-pack/test/alerting_api_integration/observability/helpers/alerting_api_helper.ts index 1a66aa2fcd9ed..c4ec1a9180541 100644 --- a/x-pack/test/alerting_api_integration/observability/helpers/alerting_api_helper.ts +++ b/x-pack/test/alerting_api_integration/observability/helpers/alerting_api_helper.ts @@ -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({ 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({ 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}`); diff --git a/x-pack/test/alerting_api_integration/observability/helpers/alerting_wait_for_helpers.ts b/x-pack/test/alerting_api_integration/observability/helpers/alerting_wait_for_helpers.ts index 17f45b8129d91..edc5ca8d35a60 100644 --- a/x-pack/test/alerting_api_integration/observability/helpers/alerting_wait_for_helpers.ts +++ b/x-pack/test/alerting_api_integration/observability/helpers/alerting_wait_for_helpers.ts @@ -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({ timeout = TIMEOUT, retries = RETRIES, retryDelay = RETRY_DELAY, + filters, }: { esClient: Client; indexName: string; @@ -72,6 +74,7 @@ export async function waitForDocumentInIndex({ timeout?: number; retries?: number; retryDelay?: number; + filters?: QueryDslQueryContainer[]; }): Promise>> { return await retry>>({ testFn: async () => { @@ -79,6 +82,15 @@ export async function waitForDocumentInIndex({ 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({ ruleId, retryService, logger, + filters = [], + retryDelay, }: { esClient: Client; indexName: string; ruleId: string; retryService: RetryService; logger: ToolingLog; + filters?: QueryDslQueryContainer[]; + retryDelay?: number; }): Promise>> { return await retry>>({ testFn: async () => { @@ -117,14 +133,21 @@ export async function waitForAlertInIndex({ 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({ retryService, timeout: TIMEOUT, retries: RETRIES, - retryDelay: RETRY_DELAY, + retryDelay: retryDelay ?? RETRY_DELAY, }); } diff --git a/x-pack/test/alerting_api_integration/observability/index.ts b/x-pack/test/alerting_api_integration/observability/index.ts index 812123dd96b13..547c05a46bfcd 100644 --- a/x-pack/test/alerting_api_integration/observability/index.ts +++ b/x-pack/test/alerting_api_integration/observability/index.ts @@ -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')); }); }); } diff --git a/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts new file mode 100644 index 0000000000000..6600054e03ab9 --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts @@ -0,0 +1,1000 @@ +/* + * 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 moment from 'moment'; +import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { StatusRuleParams } from '@kbn/synthetics-plugin/common/rules/status_rule'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { SyntheticsRuleHelper, SYNTHETICS_ALERT_ACTION_INDEX } from './synthetics_rule_helper'; +import { waitForDocumentInIndex } from '../helpers/alerting_wait_for_helpers'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const server = getService('kibanaServer'); + const retryService = getService('retry'); + const ruleHelper = new SyntheticsRuleHelper(getService); + const logger = getService('log'); + const esClient = getService('es'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const supertest = getService('supertest'); + + describe('SyntheticsCustomStatusRule', () => { + const SYNTHETICS_RULE_ALERT_INDEX = '.alerts-observability.uptime.alerts-default'; + + before(async () => { + await server.savedObjects.cleanStandardList(); + await esDeleteAllIndices([SYNTHETICS_ALERT_ACTION_INDEX]); + await ruleHelper.createIndexAction(); + await supertest + .put(SYNTHETICS_API_URLS.SYNTHETICS_ENABLEMENT) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + after(async () => { + await server.savedObjects.cleanStandardList(); + await esDeleteAllIndices([SYNTHETICS_ALERT_ACTION_INDEX]); + await esClient.deleteByQuery({ + index: SYNTHETICS_RULE_ALERT_INDEX, + query: { match_all: {} }, + }); + }); + + /* 1. create a monitor + 2. create a custom rule + 3. create a down check scenario + 4. verify alert + 5. create an up check scenario + 6. verify recovered alert + when verifying recovered alert check: + - reason + - recoveryReason + - recoveryStatus + - locationNames + - link message + - locationId + when down recovered alert check + - reason + - locationNames + - link message + - locationId + */ + + describe('NumberOfChecks - location threshold = 1 - grouped by location - 1 location down', () => { + let ruleId = ''; + let monitor: any; + let docs: any[] = []; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor('Monitor check based at ' + moment().format('LLL')); + expect(monitor).to.have.property('id'); + + docs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 5, + }); + }); + + it('creates a custom rule', async () => { + const params = { + condition: { + locationsThreshold: 1, + window: { + numberOfChecks: 5, + }, + groupBy: 'locationId', + downThreshold: 5, + }, + monitorIds: [monitor.id], + }; + const rule = await ruleHelper.createCustomStatusRule({ + params, + name: 'When down 5 times from 1 location', + }); + ruleId = rule.id; + expect(rule.params).to.eql(params); + }); + + it('should trigger down alert', async function () { + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + }); + + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" from Dev Service is down. Monitor is down 5 times within the last 5 checks. Alert when 5 out of the last 5 checks are down from at least 1 location.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(downResponse.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service is down. Monitor is down 5 times within the last 5 checks. Alert when 5 out of the last 5 checks are down from at least 1 location.` + ); + expect(downResponse.hits.hits[0]._source).property('locationNames', 'Dev Service'); + expect(downResponse.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[0]._source).property('locationId', 'dev'); + }); + + it('should trigger recovered alert', async function () { + docs = await ruleHelper.makeSummaries({ + monitor, + upChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [{ term: { 'kibana.alert.status': 'recovered' } }], + }); + + const alert = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'recovered'); + const recoveredResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 2, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(recoveredResponse.hits.hits[1]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service is recovered. Alert when 5 out of the last 5 checks are down from at least 1 location.` + ); + expect(recoveredResponse.hits.hits[1]._source).property('locationNames', 'Dev Service'); + expect(recoveredResponse.hits.hits[1]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveredResponse.hits.hits[1]._source).property('locationId', 'dev'); + expect(recoveredResponse.hits.hits[1]._source).property( + 'recoveryReason', + `the monitor is now up again. It ran successfully at ${moment(docs[0]['@timestamp']) + .tz('UTC') + .format('MMM D, YYYY @ HH:mm:ss.SSS')}` + ); + expect(recoveredResponse.hits.hits[1]._source).property('recoveryStatus', 'is now up'); + }); + }); + + describe('NumberOfChecks - Location threshold = 1 - grouped by location - 2 down locations', () => { + let ruleId = ''; + let monitor: any; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor('Monitor location based at ' + moment().format('LT')); + expect(monitor).to.have.property('id'); + }); + + it('creates a custom rule with 1 location threshold grouped by location', async () => { + const params: StatusRuleParams = { + condition: { + window: { + numberOfChecks: 1, + }, + groupBy: 'locationId', + locationsThreshold: 1, + downThreshold: 1, + }, + monitorIds: [monitor.id], + }; + + const rule = await ruleHelper.createCustomStatusRule({ + params, + }); + ruleId = rule.id; + expect(rule.params).to.eql(params); + + await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + location: { + id: 'dev2', + label: 'Dev Service 2', + }, + }); + const downDocs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'active' } }, + { term: { 'monitor.id': monitor.id } }, + { range: { '@timestamp': { gte: downDocs[0]['@timestamp'] } } }, + ], + }); + + response.hits.hits.forEach((hit: any) => { + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" from ${alert['location.name']} 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.` + ); + }); + + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + locationNames: string; + locationId: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 2, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + const lastTwoHits = downResponse.hits.hits.slice(-2).map((hit) => hit._source); + + lastTwoHits.forEach((hit) => { + expect(hit).property( + 'reason', + `Monitor "${monitor.name}" from ${hit?.locationNames} 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.` + ); + expect(hit).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=${hit?.locationId}` + ); + }); + }); + }); + + describe('NumberOfChecks - location threshold = 1 - ungrouped - 1 down location', () => { + let ruleId = ''; + let monitor: any; + let docs: any[] = []; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor( + `Monitor check based at ${moment().format('LLL')} ungrouped` + ); + expect(monitor).to.have.property('id'); + }); + + it('creates a custom rule with 1 location threshold ungrouped', async () => { + const params = { + condition: { + locationsThreshold: 1, + window: { + numberOfChecks: 5, + }, + groupBy: 'none', + downThreshold: 5, + }, + monitorIds: [monitor.id], + }; + const rule = await ruleHelper.createCustomStatusRule({ + params, + name: 'Status based on number of checks', + }); + ruleId = rule.id; + expect(rule.params).to.eql(params); + }); + + it('should trigger down for ungrouped', async () => { + docs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 5, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'active' } }, + { term: { 'monitor.id': monitor.id } }, + { range: { '@timestamp': { gte: docs[0]['@timestamp'] } } }, + ], + }); + + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" is down 5 times from Dev Service. Alert when down 5 times out of the last 5 checks from at least 1 location.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 1, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(downResponse.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" is down 5 times from Dev Service. Alert when down 5 times out of the last 5 checks from at least 1 location.` + ); + expect(downResponse.hits.hits[0]._source).property('locationNames', 'Dev Service'); + expect(downResponse.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[0]._source).property('locationId', 'dev'); + }); + + it('should trigger recovered alert', async () => { + const upDocs = await ruleHelper.makeSummaries({ + monitor, + upChecks: 1, + }); + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'recovered' } }, + { term: { 'monitor.id': monitor.id } }, + { range: { '@timestamp': { gte: upDocs[0]['@timestamp'] } } }, + ], + }); + expect(response.hits.hits?.[0]._source).property('kibana.alert.status', 'recovered'); + const recoveredResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 2, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(recoveredResponse.hits.hits[1]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service is recovered. Alert when 5 out of the last 5 checks are down from at least 1 location.` + ); + expect(recoveredResponse.hits.hits[1]._source).property('locationNames', 'Dev Service'); + expect(recoveredResponse.hits.hits[1]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveredResponse.hits.hits[1]._source).property('locationId', 'dev'); + expect(recoveredResponse.hits.hits[1]._source).property( + 'recoveryReason', + 'the alert condition is no longer met' + ); + expect(recoveredResponse.hits.hits[1]._source).property('recoveryStatus', 'has recovered'); + }); + }); + + describe('NumberOfChecks - Location threshold > 1 - ungrouped - 2 down locations', () => { + let ruleId = ''; + let monitor: any; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor( + `Monitor location based at ${moment().format('LT')} ungrouped 2 locations` + ); + expect(monitor).to.have.property('id'); + }); + + it('creates a custom rule with location threshold', async () => { + const params: StatusRuleParams = { + condition: { + locationsThreshold: 2, + window: { + numberOfChecks: 1, + }, + groupBy: 'none', + downThreshold: 1, + }, + monitorIds: [monitor.id], + }; + const rule = await ruleHelper.createCustomStatusRule({ + params, + name: 'When down from 2 locations', + }); + ruleId = rule.id; + expect(rule.params).to.eql(params); + }); + + it('should not trigger down alert based on location threshold with one location down', async () => { + // first down check from dev 1 + const docs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + }); + // ensure alert does not fire + try { + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'active' } }, + { + term: { 'monitor.id': monitor.id }, + }, + { + range: { + '@timestamp': { + gte: docs[0]['@timestamp'], + }, + }, + }, + ], + }); + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" is down from 1 location (Dev Service). Alert when monitor is down from 1 location.` + ); + throw new Error('Alert was triggered when condition should not be met'); + } catch (e) { + if (e.message === 'Alert was triggered when condition should not be met') { + throw e; + } + } + }); + + it('should trigger down alert based on location threshold with two locations down', async () => { + // 1st down check from dev 2 + await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + location: { + id: 'dev2', + label: 'Dev Service 2', + }, + }); + // 2nd down check from dev 1 + const downDocs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'active' } }, + { + term: { 'monitor.id': monitor.id }, + }, + { + range: { + '@timestamp': { + gte: downDocs[0]['@timestamp'], + }, + }, + }, + ], + }); + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" is down 1 time from Dev Service and 1 time from Dev Service 2. Alert when down 1 time out of the last 1 checks from at least 2 locations.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(downResponse.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" is down 1 time from Dev Service and 1 time from Dev Service 2. Alert when down 1 time out of the last 1 checks from at least 2 locations.` + ); + expect(downResponse.hits.hits[0]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(downResponse.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[0]._source).property('locationId', 'dev and dev2'); + }); + + it('should trigger recovered alert', async () => { + const docs = await ruleHelper.makeSummaries({ + monitor, + upChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'recovered' } }, + { + term: { 'monitor.id': monitor.id }, + }, + { + range: { + '@timestamp': { + gte: docs[0]['@timestamp'], + }, + }, + }, + ], + }); + + const alert = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'recovered'); + const recoveryResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 2, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(recoveryResponse.hits.hits[1]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service and Dev Service 2 is recovered. Alert when 1 out of the last 1 checks are down from at least 2 locations.` + ); + expect(recoveryResponse.hits.hits[1]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(recoveryResponse.hits.hits[1]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveryResponse.hits.hits[1]._source).property('locationId', 'dev and dev2'); + }); + + let downDocs: any[] = []; + + it('should be down again', async () => { + downDocs = await ruleHelper.makeSummaries({ + monitor, + downChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'active' } }, + { term: { 'monitor.id': monitor.id } }, + { range: { '@timestamp': { gte: downDocs[0]['@timestamp'] } } }, + ], + }); + + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" is down 1 time from Dev Service and 1 time from Dev Service 2. Alert when down 1 time out of the last 1 checks from at least 2 locations.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 3, + filters: [{ term: { 'monitor.id': monitor.id } }], + }); + expect(downResponse.hits.hits[2]._source).property( + 'reason', + `Monitor "${monitor.name}" is down 1 time from Dev Service and 1 time from Dev Service 2. Alert when down 1 time out of the last 1 checks from at least 2 locations.` + ); + expect(downResponse.hits.hits[2]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(downResponse.hits.hits[2]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[2]._source).property('locationId', 'dev and dev2'); + }); + + it('should trigger recovered alert when the location threshold is no longer met', async () => { + // 2nd down check from dev 1 + const upDocs = await ruleHelper.makeSummaries({ + monitor, + upChecks: 1, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'recovered' } }, + { term: { 'monitor.id': monitor.id } }, + { range: { '@timestamp': { gte: upDocs[0]['@timestamp'] } } }, + ], + }); + const alert = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'recovered'); + const recoveryResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + docCountTarget: 4, + filters: [{ term: { 'monitor.id': monitor.id } }], + }); + expect(recoveryResponse.hits.hits[3]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service and Dev Service 2 is recovered. Alert when 1 out of the last 1 checks are down from at least 2 locations.` + ); + expect(recoveryResponse.hits.hits[3]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(recoveryResponse.hits.hits[3]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveryResponse.hits.hits[3]._source).property('locationId', 'dev and dev2'); + expect(recoveryResponse.hits.hits[3]._source).property( + 'recoveryReason', + 'the alert condition is no longer met' + ); + expect(recoveryResponse.hits.hits[3]._source).property('recoveryStatus', 'has recovered'); + }); + }); + + describe('TimeWindow - Location threshold = 1 - grouped by location - 1 down location', () => { + let ruleId = ''; + let monitor: any; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor('Monitor time based at ' + moment().format('LT')); + expect(monitor).to.have.property('id'); + }); + + it('creates a custom rule with time based window', async () => { + const params: StatusRuleParams = { + condition: { + locationsThreshold: 1, + window: { + time: { + unit: 'm', + size: 5, + }, + }, + groupBy: 'locationId', + downThreshold: 5, + }, + monitorIds: [monitor.id], + }; + const rule = await ruleHelper.createCustomStatusRule({ + params, + name: 'Status based on checks in a time window', + }); + expect(rule).to.have.property('id'); + ruleId = rule.id; + expect(rule.params).to.eql(params); + }); + + it('should trigger down alert', async function () { + await ruleHelper.makeSummaries({ + monitor, + downChecks: 5, + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [{ term: { 'monitor.id': monitor.id } }], + }); + + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" from Dev Service is down. Alert when 5 checks are down within the last 5 minutes from at least 1 location.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + expect(downResponse.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service is down. Alert when 5 checks are down within the last 5 minutes from at least 1 location.` + ); + expect(downResponse.hits.hits[0]._source).property('locationNames', 'Dev Service'); + expect(downResponse.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[0]._source).property('locationId', 'dev'); + }); + + it('should trigger recovered alert', async function () { + // wait 1 minute for at least 1 down check to fall out of the time window + await new Promise((resolve) => setTimeout(resolve, 30_000)); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'recovered' } }, + { term: { 'monitor.id': monitor.id } }, + ], + }); + + const alert = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'recovered'); + const recoveryResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + docCountTarget: 2, + }); + expect(recoveryResponse.hits.hits[1]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service is recovered. Alert when 5 checks are down within the last 5 minutes from at least 1 location.` + ); + expect(recoveryResponse.hits.hits[1]._source).property( + 'recoveryReason', + 'the alert condition is no longer met' + ); + expect(recoveryResponse.hits.hits[1]._source).property('recoveryStatus', 'has recovered'); + expect(recoveryResponse.hits.hits[1]._source).property('locationNames', 'Dev Service'); + expect(recoveryResponse.hits.hits[1]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveryResponse.hits.hits[1]._source).property('locationId', 'dev'); + }); + }); + + describe('TimeWindow - Location threshold = 1 - grouped by location - 2 down location', () => { + let ruleId = ''; + let monitor: any; + + it('creates a monitor', async () => { + monitor = await ruleHelper.addMonitor( + `Monitor time based at ${moment().format('LT')} grouped 2 locations` + ); + expect(monitor).to.have.property('id'); + }); + + it('creates a custom rule with time based window', async () => { + const params: StatusRuleParams = { + condition: { + window: { + time: { + unit: 'm', + size: 5, + }, + }, + groupBy: 'locationId', + locationsThreshold: 2, + downThreshold: 5, + }, + monitorIds: [monitor.id], + }; + const rule = await ruleHelper.createCustomStatusRule({ + params, + name: 'Status based on checks in a time window when down from 2 locations', + }); + expect(rule).to.have.property('id'); + ruleId = rule.id; + expect(rule.params).to.eql(params); + }); + + it('should trigger down alert', async function () { + // Generate data for 2 locations + await ruleHelper.makeSummaries({ + monitor, + downChecks: 5, + location: monitor.locations[0], + }); + await ruleHelper.makeSummaries({ + monitor, + downChecks: 5, + location: monitor.locations[1], + }); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [{ term: { 'monitor.id': monitor.id } }], + }); + + const alert: any = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'active'); + expect(alert['kibana.alert.reason']).to.eql( + `Monitor "${monitor.name}" is down 5 times from Dev Service and 5 times from Dev Service 2. Alert when down 5 times within the last 5 minutes from at least 2 locations.` + ); + const downResponse = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + }); + + expect(downResponse.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" is down 5 times from Dev Service and 5 times from Dev Service 2. Alert when down 5 times within the last 5 minutes from at least 2 locations.` + ); + expect(downResponse.hits.hits[0]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(downResponse.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(downResponse.hits.hits[0]._source).property('locationId', 'dev and dev2'); + }); + + it('should trigger alert action', async function () { + const alertAction = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + docCountTarget: 1, + }); + + expect(alertAction.hits.hits[0]._source).property( + 'reason', + `Monitor "${monitor.name}" is down 5 times from Dev Service and 5 times from Dev Service 2. Alert when down 5 times within the last 5 minutes from at least 2 locations.` + ); + expect(alertAction.hits.hits[0]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(alertAction.hits.hits[0]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(alertAction.hits.hits[0]._source).property('locationId', 'dev and dev2'); + }); + + it('should trigger recovered alert', async function () { + // wait 30 secs for at least 1 down check to fall out of the time window + await new Promise((resolve) => setTimeout(resolve, 30_000)); + + const response = await ruleHelper.waitForStatusAlert({ + ruleId, + filters: [ + { term: { 'kibana.alert.status': 'recovered' } }, + { term: { 'monitor.id': monitor.id } }, + ], + }); + + const alert = response.hits.hits?.[0]._source; + expect(alert).to.have.property('kibana.alert.status', 'recovered'); + const recoveryAction = await waitForDocumentInIndex<{ + ruleType: string; + alertDetailsUrl: string; + reason: string; + }>({ + esClient, + indexName: SYNTHETICS_ALERT_ACTION_INDEX, + retryService, + logger, + filters: [ + { + term: { 'monitor.id': monitor.id }, + }, + ], + docCountTarget: 2, + }); + + expect(recoveryAction.hits.hits[1]._source).property( + 'reason', + `Monitor "${monitor.name}" from Dev Service and Dev Service 2 is recovered. Alert when 5 checks are down within the last 5 minutes from at least 2 locations.` + ); + expect(recoveryAction.hits.hits[1]._source).property( + 'recoveryReason', + 'the alert condition is no longer met' + ); + expect(recoveryAction.hits.hits[1]._source).property('recoveryStatus', 'has recovered'); + expect(recoveryAction.hits.hits[1]._source).property( + 'locationNames', + 'Dev Service and Dev Service 2' + ); + expect(recoveryAction.hits.hits[1]._source).property( + 'linkMessage', + `- Link: https://localhost:5601/app/synthetics/monitor/${monitor.id}/errors/Test%20private%20location-18524a3d9a7-0?locationId=dev` + ); + expect(recoveryAction.hits.hits[1]._source).property('locationId', 'dev and dev2'); + }); + }); + + // TimeWindow - Location threshold = 1 - ungrouped - 1 down location + + // TimeWindow - Location threshold > 1 - ungrouped - 2 down locations + }); +} diff --git a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics/data.ts similarity index 59% rename from x-pack/test/alerting_api_integration/observability/synthetics_rule.ts rename to x-pack/test/alerting_api_integration/observability/synthetics/data.ts index 864d73a991e50..566bb410d6321 100644 --- a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts +++ b/x-pack/test/alerting_api_integration/observability/synthetics/data.ts @@ -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', diff --git a/x-pack/test/alerting_api_integration/observability/synthetics/private_location_test_service.ts b/x-pack/test/alerting_api_integration/observability/synthetics/private_location_test_service.ts new file mode 100644 index 0000000000000..e9ac7237dca52 --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/synthetics/private_location_test_service.ts @@ -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; + } +} diff --git a/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_default_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_default_rule.ts new file mode 100644 index 0000000000000..39f36b71b52ee --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_default_rule.ts @@ -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']), + }; +}; diff --git a/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_rule_helper.ts b/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_rule_helper.ts new file mode 100644 index 0000000000000..9321bc0935a80 --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/synthetics/synthetics_rule_helper.ts @@ -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({ + 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) { + 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; + } +} diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts index 044e66fe239f7..051ae14396687 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts @@ -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 = [ - { - 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, diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts index bd6f94a44f6c1..9654a2bc43404 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts @@ -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': { diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts index 5da370d1c634f..082d1aebd6d76 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts @@ -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." ); }); diff --git a/x-pack/test/api_integration/apis/synthetics/edit_monitor_public_api.ts b/x-pack/test/api_integration/apis/synthetics/edit_monitor_public_api.ts index fa7c780a2d971..e52d83ce9b263 100644 --- a/x-pack/test/api_integration/apis/synthetics/edit_monitor_public_api.ts +++ b/x-pack/test/api_integration/apis/synthetics/edit_monitor_public_api.ts @@ -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." ); }); diff --git a/x-pack/test/api_integration/apis/synthetics/suggestions.ts b/x-pack/test/api_integration/apis/synthetics/suggestions.ts index dab822f832c08..043c1c4da0ee6 100644 --- a/x-pack/test/api_integration/apis/synthetics/suggestions.ts +++ b/x-pack/test/api_integration/apis/synthetics/suggestions.ts @@ -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,23 +242,18 @@ export default function ({ getService }: FtrProviderContext) { value: expect.any(String), })) ), - projects: [ + monitorTypes: [ { count: 2, - label: project, - value: project, + label: 'icmp', + value: 'icmp', }, ], - monitorTypes: [ - // { - // count: 20, - // label: 'http', - // value: 'http', - // }, + projects: [ { count: 2, - label: 'icmp', - value: 'icmp', + label: project, + value: project, }, ], tags: expect.arrayContaining([ diff --git a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts index c262cc3c79bdc..8e0aaeff21580 100644 --- a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts +++ b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts @@ -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, diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 8918b2848bc36..038e9baa5086a 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -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" ] } diff --git a/yarn.lock b/yarn.lock index acedd70e165ec..696a443817d64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""