From dd688f5377a233cac8bade621af186b427b229a2 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 15 Dec 2020 15:07:28 -0600 Subject: [PATCH] APM Alerts Preview charts (#85868) (#85890) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../action_menu/alerting_popover_flyout.tsx | 2 +- .../index.tsx | 0 .../alerting/chart_preview/index.tsx | 112 ++++++++++++++++ .../index.stories.tsx | 0 .../index.tsx | 45 +++++-- .../apm/public/components/alerting/fields.tsx | 2 +- .../apm/public/components/alerting/helper.ts | 17 +++ .../alerting/register_apm_alerts.ts | 8 +- .../index.tsx | 5 +- .../popover_expression}/index.tsx | 0 .../service_alert_trigger.test.tsx | 33 +++++ .../index.stories.tsx | 0 .../index.tsx | 85 ++++++++++-- .../index.tsx | 4 +- .../select_anomaly_severity.test.tsx | 0 .../select_anomaly_severity.tsx | 0 .../index.tsx | 59 +++++++-- .../chart_preview/get_transaction_duration.ts | 93 +++++++++++++ .../get_transaction_error_count.ts | 63 +++++++++ .../get_transaction_error_rate.ts | 84 ++++++++++++ .../apm/server/routes/alerts/chart_preview.ts | 72 ++++++++++ .../apm/server/routes/create_apm_api.ts | 12 +- .../basic/tests/alerts/chart_preview.ts | 124 ++++++++++++++++++ .../apm_api_integration/basic/tests/index.ts | 4 + 24 files changed, 781 insertions(+), 43 deletions(-) rename x-pack/plugins/apm/public/components/alerting/{AlertingFlyout => alerting_flyout}/index.tsx (100%) create mode 100644 x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx rename x-pack/plugins/apm/public/components/alerting/{ErrorCountAlertTrigger => error_count_alert_trigger}/index.stories.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{ErrorCountAlertTrigger => error_count_alert_trigger}/index.tsx (65%) create mode 100644 x-pack/plugins/apm/public/components/alerting/helper.ts rename x-pack/plugins/apm/public/components/alerting/{ServiceAlertTrigger => service_alert_trigger}/index.tsx (92%) rename x-pack/plugins/apm/public/components/alerting/{ServiceAlertTrigger/PopoverExpression => service_alert_trigger/popover_expression}/index.tsx (100%) create mode 100644 x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx rename x-pack/plugins/apm/public/components/alerting/{TransactionDurationAlertTrigger => transaction_duration_alert_trigger}/index.stories.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{TransactionDurationAlertTrigger => transaction_duration_alert_trigger}/index.tsx (69%) rename x-pack/plugins/apm/public/components/alerting/{TransactionDurationAnomalyAlertTrigger => transaction_duration_anomaly_alert_trigger}/index.tsx (96%) rename x-pack/plugins/apm/public/components/alerting/{TransactionDurationAnomalyAlertTrigger => transaction_duration_anomaly_alert_trigger}/select_anomaly_severity.test.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{TransactionDurationAnomalyAlertTrigger => transaction_duration_anomaly_alert_trigger}/select_anomaly_severity.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{TransactionErrorRateAlertTrigger => transaction_error_rate_alert_trigger}/index.tsx (71%) create mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts create mode 100644 x-pack/plugins/apm/server/routes/alerts/chart_preview.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts diff --git a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx index 394b4caea3e7b..395233735a9d5 100644 --- a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { IBasePath } from '../../../../../../src/core/public'; import { AlertType } from '../../../common/alert_types'; -import { AlertingFlyout } from '../../components/alerting/AlertingFlyout'; +import { AlertingFlyout } from '../../components/alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx rename to x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx new file mode 100644 index 0000000000000..1ed5748cd757e --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AnnotationDomainTypes, + Axis, + BarSeries, + Chart, + LineAnnotation, + niceTimeFormatter, + Position, + RectAnnotation, + RectAnnotationDatum, + ScaleType, + Settings, + TickFormatter, +} from '@elastic/charts'; +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { Coordinate } from '../../../../typings/timeseries'; +import { useTheme } from '../../../hooks/use_theme'; + +interface ChartPreviewProps { + yTickFormat?: TickFormatter; + data?: Coordinate[]; + threshold: number; +} + +export function ChartPreview({ + data = [], + yTickFormat, + threshold, +}: ChartPreviewProps) { + const theme = useTheme(); + const thresholdOpacity = 0.3; + const timestamps = data.map((d) => d.x); + const xMin = Math.min(...timestamps); + const xMax = Math.max(...timestamps); + const xFormatter = niceTimeFormatter([xMin, xMax]); + + // Make the maximum Y value either the actual max or 20% more than the threshold + const values = data.map((d) => d.y ?? 0); + const yMax = Math.max(...values, threshold * 1.2); + + const style = { + fill: theme.eui.euiColorVis9, + line: { + strokeWidth: 2, + stroke: theme.eui.euiColorVis9, + opacity: 1, + }, + opacity: thresholdOpacity, + }; + + const rectDataValues: RectAnnotationDatum[] = [ + { + coordinates: { + x0: null, + x1: null, + y0: threshold, + y1: null, + }, + }, + ]; + + return ( + <> + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx similarity index 65% rename from x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index efa792ff44273..cce973f8587da 100644 --- a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -8,12 +8,17 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; +import { asInteger } from '../../../../common/utils/formatters'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { EnvironmentField, ServiceField, IsAboveField } from '../fields'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { ChartPreview } from '../chart_preview'; +import { EnvironmentField, IsAboveField, ServiceField } from '../fields'; +import { getAbsoluteTimeRange } from '../helper'; +import { ServiceAlertTrigger } from '../service_alert_trigger'; export interface AlertParams { windowSize: number; @@ -40,6 +45,23 @@ export function ErrorCountAlertTrigger(props: Props) { end, }); + const { threshold, windowSize, windowUnit, environment } = alertParams; + + const { data } = useFetcher(() => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + environment, + serviceName, + }, + }, + }); + } + }, [windowSize, windowUnit, environment, serviceName]); + const defaults = { threshold: 25, windowSize: 1, @@ -64,14 +86,14 @@ export function ErrorCountAlertTrigger(props: Props) { unit={i18n.translate('xpack.apm.errorCountAlertTrigger.errors', { defaultMessage: ' errors', })} - onChange={(value) => setAlertParams('threshold', value)} + onChange={(value) => setAlertParams('threshold', value || 0)} />, - setAlertParams('windowSize', windowSize || '') + onChangeWindowSize={(timeWindowSize) => + setAlertParams('windowSize', timeWindowSize || '') } - onChangeWindowUnit={(windowUnit) => - setAlertParams('windowUnit', windowUnit) + onChangeWindowUnit={(timeWindowUnit) => + setAlertParams('windowUnit', timeWindowUnit) } timeWindowSize={params.windowSize} timeWindowUnit={params.windowUnit} @@ -82,6 +104,10 @@ export function ErrorCountAlertTrigger(props: Props) { />, ]; + const chartPreview = ( + + ); + return ( ); } diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index 858604d2baa2a..9e814bb1b58c5 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSelectOption } from '@elastic/eui'; import { getEnvironmentLabel } from '../../../common/environment_filter_values'; -import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression'; +import { PopoverExpression } from './service_alert_trigger/popover_expression'; const ALL_OPTION = i18n.translate('xpack.apm.alerting.fields.all_option', { defaultMessage: 'All', diff --git a/x-pack/plugins/apm/public/components/alerting/helper.ts b/x-pack/plugins/apm/public/components/alerting/helper.ts new file mode 100644 index 0000000000000..fd3aebc7495a1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/helper.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import datemath from '@elastic/datemath'; + +export function getAbsoluteTimeRange(windowSize: number, windowUnit: string) { + const now = new Date().toISOString(); + + return { + start: + datemath.parse(`now-${windowSize}${windowUnit}`)?.toISOString() ?? now, + end: now, + }; +} diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 988e335af5b7c..6dc2cb3163b1f 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -25,7 +25,7 @@ export function registerApmAlerts( documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, - alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), + alertParamsExpression: lazy(() => import('./error_count_alert_trigger')), validate: () => ({ errors: [], }), @@ -60,7 +60,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./TransactionDurationAlertTrigger') + () => import('./transaction_duration_alert_trigger') ), validate: () => ({ errors: [], @@ -97,7 +97,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./TransactionErrorRateAlertTrigger') + () => import('./transaction_error_rate_alert_trigger') ), validate: () => ({ errors: [], @@ -134,7 +134,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./TransactionDurationAnomalyAlertTrigger') + () => import('./transaction_duration_anomaly_alert_trigger') ), validate: () => ({ errors: [], diff --git a/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx similarity index 92% rename from x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx index b4d3e8f3ad241..0a12f79bf61a9 100644 --- a/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiFlexGrid, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useEffect } from 'react'; -import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { useParams } from 'react-router-dom'; interface Props { @@ -14,6 +14,7 @@ interface Props { setAlertProperty: (key: string, value: any) => void; defaults: Record; fields: React.ReactNode[]; + chartPreview?: React.ReactNode; } export function ServiceAlertTrigger(props: Props) { @@ -25,6 +26,7 @@ export function ServiceAlertTrigger(props: Props) { setAlertProperty, alertTypeName, defaults, + chartPreview, } = props; const params: Record = { @@ -61,6 +63,7 @@ export function ServiceAlertTrigger(props: Props) { ))} + {chartPreview} ); diff --git a/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/popover_expression/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/PopoverExpression/index.tsx rename to x-pack/plugins/apm/public/components/alerting/service_alert_trigger/popover_expression/index.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx new file mode 100644 index 0000000000000..72611043bbed3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { render } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ServiceAlertTrigger } from './'; + +function Wrapper({ children }: { children?: ReactNode }) { + return {children}; +} + +describe('ServiceAlertTrigger', () => { + it('renders', () => { + expect(() => + render( + {}} + setAlertProperty={() => {}} + />, + { + wrapper: Wrapper, + } + ) + ).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx similarity index 69% rename from x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 3566850aa24c4..22840bc2e6ed0 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -4,24 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiSelect } from '@elastic/eui'; -import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { map } from 'lodash'; import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useFetcher } from '../../../../../observability/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; -import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { getDurationFormatter } from '../../../../common/utils/formatters'; +import { TimeSeries } from '../../../../typings/timeseries'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../../shared/charts/transaction_charts/helper'; +import { ChartPreview } from '../chart_preview'; import { EnvironmentField, + IsAboveField, ServiceField, TransactionTypeField, - IsAboveField, } from '../fields'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { getAbsoluteTimeRange } from '../helper'; +import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { PopoverExpression } from '../service_alert_trigger/popover_expression'; interface AlertParams { windowSize: number; @@ -63,14 +73,62 @@ interface Props { export function TransactionDurationAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { transactionTypes } = useApmServiceContext(); + const { transactionTypes, transactionType } = useApmServiceContext(); const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end, transactionType } = urlParams; + const { start, end } = urlParams; const { environmentOptions } = useEnvironmentsFetcher({ serviceName, start, end, }); + const { + aggregationType, + environment, + threshold, + windowSize, + windowUnit, + } = alertParams; + + const { data } = useFetcher(() => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + aggregationType, + environment, + serviceName, + transactionType: alertParams.transactionType, + }, + }, + }); + } + }, [ + aggregationType, + environment, + serviceName, + alertParams.transactionType, + windowSize, + windowUnit, + ]); + + const maxY = getMaxY([ + { data: data ?? [] } as TimeSeries<{ x: number; y: number | null }>, + ]); + const formatter = getDurationFormatter(maxY); + const yTickFormat = getResponseTimeTickFormatter(formatter); + + // The threshold from the form is in ms. Convert to µs. + const thresholdMs = threshold * 1000; + + const chartPreview = ( + + ); if (!transactionTypes.length || !serviceName) { return null; @@ -81,9 +139,7 @@ export function TransactionDurationAlertTrigger(props: Props) { aggregationType: 'avg', windowSize: 5, windowUnit: 'm', - - // use the current transaction type or default to the first in the list - transactionType: transactionType || transactionTypes[0], + transactionType, environment: urlParams.environment || ENVIRONMENT_ALL.value, }; @@ -127,7 +183,7 @@ export function TransactionDurationAlertTrigger(props: Props) { unit={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { defaultMessage: 'ms', })} - onChange={(value) => setAlertParams('threshold', value)} + onChange={(value) => setAlertParams('threshold', value || 0)} />, @@ -148,8 +204,9 @@ export function TransactionDurationAlertTrigger(props: Props) { return ( diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx index ff5939c601375..10c4bbff08396 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx @@ -11,8 +11,8 @@ import { ANOMALY_SEVERITY } from '../../../../../ml/common'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; +import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { PopoverExpression } from '../service_alert_trigger/popover_expression'; import { AnomalySeverity, SelectAnomalySeverity, diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.test.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.test.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx similarity index 71% rename from x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index f723febde389d..9707df9e86335 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -3,22 +3,26 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useParams } from 'react-router-dom'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; -import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; - +import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { asPercent } from '../../../../common/utils/formatters'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { ChartPreview } from '../chart_preview'; import { - ServiceField, - TransactionTypeField, EnvironmentField, IsAboveField, + ServiceField, + TransactionTypeField, } from '../fields'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { getAbsoluteTimeRange } from '../helper'; +import { ServiceAlertTrigger } from '../service_alert_trigger'; interface AlertParams { windowSize: number; @@ -47,6 +51,32 @@ export function TransactionErrorRateAlertTrigger(props: Props) { end, }); + const { threshold, windowSize, windowUnit, environment } = alertParams; + + const thresholdAsPercent = (threshold ?? 0) / 100; + + const { data } = useFetcher(() => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + environment, + serviceName, + transactionType: alertParams.transactionType, + }, + }, + }); + } + }, [ + alertParams.transactionType, + environment, + serviceName, + windowSize, + windowUnit, + ]); + if (serviceName && !transactionTypes.length) { return null; } @@ -79,7 +109,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { setAlertParams('threshold', value)} + onChange={(value) => setAlertParams('threshold', value || 0)} />, @@ -97,6 +127,14 @@ export function TransactionErrorRateAlertTrigger(props: Props) { />, ]; + const chartPreview = ( + asPercent(d, 1)} + threshold={thresholdAsPercent} + /> + ); + return ( ); } diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts new file mode 100644 index 0000000000000..37e3a2f201fb9 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAggregationResponsePart } from '../../../../../../typings/elasticsearch/aggregations'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_DURATION, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { AlertParams } from '../../../routes/alerts/chart_preview'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; + +export async function getTransactionDurationChartPreview({ + alertParams, + setup, +}: { + alertParams: AlertParams; + setup: Setup & SetupTimeRange; +}) { + const { apmEventClient, start, end } = setup; + const { + aggregationType, + environment, + serviceName, + transactionType, + } = alertParams; + + const query = { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...(transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []), + ...getEnvironmentUiFilterES(environment), + ], + }, + }; + + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, + aggs: { + agg: + aggregationType === 'avg' + ? { avg: { field: TRANSACTION_DURATION } } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [aggregationType === '95th' ? 95 : 99], + }, + }, + }, + }, + }; + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { size: 0, query, aggs }, + }; + const resp = await apmEventClient.search(params); + + if (!resp.aggregations) { + return []; + } + + return resp.aggregations.timeseries.buckets.map((bucket) => { + const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; + const x = bucket.key; + const y = + aggregationType === 'avg' + ? (bucket.agg as MetricsAggregationResponsePart).value + : (bucket.agg as { values: Record }).values[ + percentilesKey + ]; + + return { x, y }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts new file mode 100644 index 0000000000000..28316298aeaad --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { AlertParams } from '../../../routes/alerts/chart_preview'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; + +export async function getTransactionErrorCountChartPreview({ + setup, + alertParams, +}: { + setup: Setup & SetupTimeRange; + alertParams: AlertParams; +}) { + const { apmEventClient, start, end } = setup; + const { serviceName, environment } = alertParams; + + const query = { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...getEnvironmentUiFilterES(environment), + ], + }, + }; + + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, + }, + }; + + const params = { + apm: { events: [ProcessorEvent.error] }, + body: { size: 0, query, aggs }, + }; + + const resp = await apmEventClient.search(params); + + if (!resp.aggregations) { + return []; + } + + return resp.aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.doc_count, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts new file mode 100644 index 0000000000000..fae43ef148cfa --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { AlertParams } from '../../../routes/alerts/chart_preview'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { + calculateTransactionErrorPercentage, + getOutcomeAggregation, +} from '../../helpers/transaction_error_rate'; + +export async function getTransactionErrorRateChartPreview({ + setup, + alertParams, +}: { + setup: Setup & SetupTimeRange; + alertParams: AlertParams; +}) { + const { apmEventClient, start, end } = setup; + const { serviceName, environment, transactionType } = alertParams; + + const query = { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...(transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []), + ...getEnvironmentUiFilterES(environment), + ], + }, + }; + + const outcomes = getOutcomeAggregation({ + searchAggregatedTransactions: false, + }); + + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + + const aggs = { + outcomes, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, + aggs: { outcomes }, + }, + }; + + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { size: 0, query, aggs }, + }; + + const resp = await apmEventClient.search(params); + + if (!resp.aggregations) { + return []; + } + + return resp.aggregations.timeseries.buckets.map((bucket) => { + const errorPercentage = calculateTransactionErrorPercentage( + bucket.outcomes + ); + return { + x: bucket.key, + y: errorPercentage, + }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts new file mode 100644 index 0000000000000..dc8bf45de091b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { getTransactionDurationChartPreview } from '../../lib/alerts/chart_preview/get_transaction_duration'; +import { getTransactionErrorCountChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_count'; +import { getTransactionErrorRateChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_rate'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createRoute } from '../create_route'; +import { rangeRt } from '../default_api_types'; + +const alertParamsRt = t.intersection([ + t.partial({ + aggregationType: t.union([ + t.literal('avg'), + t.literal('95th'), + t.literal('99th'), + ]), + serviceName: t.string, + environment: t.string, + transactionType: t.string, + }), + rangeRt, +]); + +export type AlertParams = t.TypeOf; + +export const transactionErrorRateChartPreview = createRoute({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', + params: t.type({ query: alertParamsRt }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { _debug, ...alertParams } = context.params.query; + + return getTransactionErrorRateChartPreview({ + setup, + alertParams, + }); + }, +}); + +export const transactionErrorCountChartPreview = createRoute({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', + params: t.type({ query: alertParamsRt }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { _debug, ...alertParams } = context.params.query; + return getTransactionErrorCountChartPreview({ + setup, + alertParams, + }); + }, +}); + +export const transactionDurationChartPreview = createRoute({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', + params: t.type({ query: alertParamsRt }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { _debug, ...alertParams } = context.params.query; + + return getTransactionDurationChartPreview({ + alertParams, + setup, + }); + }, +}); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 09938ac7563d4..5e26371b043e8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -102,6 +102,11 @@ import { rumVisitorsBreakdownRoute, rumWebCoreVitals, } from './rum_client'; +import { + transactionErrorRateChartPreview, + transactionErrorCountChartPreview, + transactionDurationChartPreview, +} from './alerts/chart_preview'; const createApmApi = () => { const api = createApi() @@ -206,7 +211,12 @@ const createApmApi = () => { .add(rumJSErrors) .add(rumUrlSearch) .add(rumLongTaskMetrics) - .add(rumHasDataRoute); + .add(rumHasDataRoute) + + // Alerting + .add(transactionErrorCountChartPreview) + .add(transactionDurationChartPreview) + .add(transactionErrorRateChartPreview); return api; }; diff --git a/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts b/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts new file mode 100644 index 0000000000000..3119de47a8635 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { format } from 'url'; +import archives from '../../../common/archives_metadata'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + const archiveName = 'apm_8.0.0'; + const { end } = archives[archiveName]; + const start = new Date(Date.parse(end) - 600000).toISOString(); + + describe('Alerting chart previews', () => { + describe('GET /api/apm/alerts/chart_preview/transaction_error_rate', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_error_rate', + query: { + start, + end, + transactionType: 'request', + serviceName: 'opbeans-java', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + + describe('GET /api/apm/alerts/chart_preview/transaction_error_count', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_error_count', + query: { + start, + end, + serviceName: 'opbeans-java', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + + describe('GET /api/apm/alerts/chart_preview/transaction_duration', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_duration', + query: { + start, + end, + serviceName: 'opbeans-java', + transactionType: 'request', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts index 902f48da92b1f..f50868ee76c1c 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -11,6 +11,10 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./feature_controls')); + describe('Alerts', function () { + loadTestFile(require.resolve('./alerts/chart_preview')); + }); + describe('Service Maps', function () { loadTestFile(require.resolve('./service_maps/service_maps')); });