From 966f00ac5956ac32e8aa76c54706cf7901328ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 28 Sep 2020 14:12:58 +0100 Subject: [PATCH 1/6] [APM] Alerting: Add global option to create all alert types (#78151) * adding alert to service page * sending on alert per service environment and transaction type * addressing PR comment * addressing PR comment Co-authored-by: Elastic Machine --- .../AlertingFlyout/index.tsx | 4 +- .../alerting/ServiceAlertTrigger/index.tsx | 16 +- .../TransactionDurationAlertTrigger/index.tsx | 10 +- .../index.tsx | 26 +- .../index.tsx | 12 +- .../components/alerting/fields.test.tsx | 61 ++++ .../apm/public/components/alerting/fields.tsx | 15 +- .../alerting/get_alert_capabilities.ts | 32 ++ .../Home/alerting_popover_flyout/index.tsx | 186 ++++++++++ .../apm/public/components/app/Home/index.tsx | 25 +- .../index.tsx | 6 +- .../components/app/ServiceDetails/index.tsx | 30 +- .../register_error_count_alert_type.test.ts | 197 +++++++++++ .../alerts/register_error_count_alert_type.ts | 81 ++++- ...action_duration_anomaly_alert_type.test.ts | 326 ++++++++++++++++++ ...transaction_duration_anomaly_alert_type.ts | 117 +++++-- ..._transaction_error_rate_alert_type.test.ts | 289 ++++++++++++++++ ...ister_transaction_error_rate_alert_type.ts | 93 ++++- .../lib/service_map/get_service_anomalies.ts | 12 +- 19 files changed, 1419 insertions(+), 119 deletions(-) rename x-pack/plugins/apm/public/components/{app/ServiceDetails/AlertIntegrations => alerting}/AlertingFlyout/index.tsx (86%) create mode 100644 x-pack/plugins/apm/public/components/alerting/fields.test.tsx create mode 100644 x-pack/plugins/apm/public/components/alerting/get_alert_capabilities.ts create mode 100644 x-pack/plugins/apm/public/components/app/Home/alerting_popover_flyout/index.tsx rename x-pack/plugins/apm/public/components/app/ServiceDetails/{AlertIntegrations => alerting_popover_flyout}/index.tsx (97%) create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx similarity index 86% rename from x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx rename to x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx index ad3f1696ad5e3..3bee6b2388264 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { AlertType } from '../../../../../../common/alert_types'; -import { AlertAdd } from '../../../../../../../triggers_actions_ui/public'; +import { AlertType } from '../../../../common/alert_types'; +import { AlertAdd } from '../../../../../triggers_actions_ui/public'; type AlertAddProps = React.ComponentProps; diff --git a/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx index 86dc7f5a90475..b4d3e8f3ad241 100644 --- a/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx @@ -34,11 +34,17 @@ export function ServiceAlertTrigger(props: Props) { useEffect(() => { // we only want to run this on mount to set default values - setAlertProperty('name', `${alertTypeName} | ${params.serviceName}`); - setAlertProperty('tags', [ - 'apm', - `service.name:${params.serviceName}`.toLowerCase(), - ]); + + const alertName = params.serviceName + ? `${alertTypeName} | ${params.serviceName}` + : alertTypeName; + setAlertProperty('name', alertName); + + const tags = ['apm']; + if (params.serviceName) { + tags.push(`service.name:${params.serviceName}`.toLowerCase()); + } + setAlertProperty('tags', tags); Object.keys(params).forEach((key) => { setAlertParams(key, params[key]); }); diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx index 3ddd623d9e848..ce98354c94c7e 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx @@ -90,16 +90,16 @@ export function TransactionDurationAlertTrigger(props: Props) { const fields = [ , - setAlertParams('environment', e.target.value)} - />, ({ text: key, value: key }))} onChange={(e) => setAlertParams('transactionType', e.target.value)} />, + setAlertParams('environment', e.target.value)} + />, (); - const { start, end } = urlParams; + const { start, end, transactionType } = urlParams; const { environmentOptions } = useEnvironments({ serviceName, start, end }); - const supportedTransactionTypes = transactionTypes.filter((transactionType) => - [TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST].includes(transactionType) - ); - if (!supportedTransactionTypes.length || !serviceName) { + if (serviceName && !transactionTypes.length) { return null; } - // 'page-load' for RUM, 'request' otherwise - const transactionType = supportedTransactionTypes[0]; - const defaults: Params = { windowSize: 15, windowUnit: 'm', - transactionType, + transactionType: transactionType || transactionTypes[0], serviceName, environment: urlParams.environment || ENVIRONMENT_ALL.value, anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, @@ -82,7 +72,11 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { const fields = [ , - , + ({ text: key, value: key }))} + onChange={(e) => setAlertParams('transactionType', e.target.value)} + />, , - setAlertParams('environment', e.target.value)} - />, ({ text: key, value: key }))} onChange={(e) => setAlertParams('transactionType', e.target.value)} />, + setAlertParams('environment', e.target.value)} + />, { + describe('Service Fiels', () => { + it('renders with value', () => { + const component = render(); + expectTextsInDocument(component, ['foo']); + }); + it('renders with All when value is not defined', () => { + const component = render(); + expectTextsInDocument(component, ['All']); + }); + }); + describe('Transaction Type Field', () => { + it('renders select field when multiple options available', () => { + const options = [ + { text: 'Foo', value: 'foo' }, + { text: 'Bar', value: 'bar' }, + ]; + const { getByText, getByTestId } = render( + + ); + + act(() => { + fireEvent.click(getByText('Foo')); + }); + + const selectBar = getByTestId('transactionTypeField'); + expect(selectBar instanceof HTMLSelectElement).toBeTruthy(); + const selectOptions = (selectBar as HTMLSelectElement).options; + expect(selectOptions.length).toEqual(2); + expect( + Object.values(selectOptions).map((option) => option.value) + ).toEqual(['foo', 'bar']); + }); + it('renders read-only field when single option available', () => { + const options = [{ text: 'Bar', value: 'bar' }]; + const component = render( + + ); + expectTextsInDocument(component, ['Bar']); + }); + it('renders read-only All option when no option available', () => { + const component = render(); + expectTextsInDocument(component, ['All']); + }); + + it('renders current value when available', () => { + const component = render(); + expectTextsInDocument(component, ['foo']); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index e145d03671a18..aac64649546cc 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -11,13 +11,17 @@ import { EuiSelectOption } from '@elastic/eui'; import { getEnvironmentLabel } from '../../../common/environment_filter_values'; import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression'; +const ALL_OPTION = i18n.translate('xpack.apm.alerting.fields.all_option', { + defaultMessage: 'All', +}); + export function ServiceField({ value }: { value?: string }) { return ( ); } @@ -53,7 +57,7 @@ export function TransactionTypeField({ options, onChange, }: { - currentValue: string; + currentValue?: string; options?: EuiSelectOption[]; onChange?: (event: React.ChangeEvent) => void; }) { @@ -61,13 +65,16 @@ export function TransactionTypeField({ defaultMessage: 'Type', }); - if (!options || options.length === 1) { - return ; + if (!options || options.length <= 1) { + return ( + + ); } return ( { + const canReadAlerts = !!capabilities.apm['alerting:show']; + const canSaveAlerts = !!capabilities.apm['alerting:save']; + const isAlertingPluginEnabled = 'alerts' in plugins; + const isAlertingAvailable = + isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts); + const isMlPluginEnabled = 'ml' in plugins; + const canReadAnomalies = !!( + isMlPluginEnabled && + capabilities.ml.canAccessML && + capabilities.ml.canGetJobs + ); + + return { + isAlertingAvailable, + canReadAlerts, + canSaveAlerts, + canReadAnomalies, + }; +}; diff --git a/x-pack/plugins/apm/public/components/app/Home/alerting_popover_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/Home/alerting_popover_flyout/index.tsx new file mode 100644 index 0000000000000..7e6331c1fa3a8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Home/alerting_popover_flyout/index.tsx @@ -0,0 +1,186 @@ +/* + * 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 { + EuiButtonEmpty, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../common/alert_types'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; +import { AlertingFlyout } from '../../../alerting/AlertingFlyout'; + +const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { + defaultMessage: 'Alerts', +}); +const transactionDurationLabel = i18n.translate( + 'xpack.apm.home.alertsMenu.transactionDuration', + { defaultMessage: 'Transaction duration' } +); +const transactionErrorRateLabel = i18n.translate( + 'xpack.apm.home.alertsMenu.transactionErrorRate', + { defaultMessage: 'Transaction error rate' } +); +const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { + defaultMessage: 'Error count', +}); +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.home.alertsMenu.createThresholdAlert', + { defaultMessage: 'Create threshold alert' } +); +const createAnomalyAlertAlertLabel = i18n.translate( + 'xpack.apm.home.alertsMenu.createAnomalyAlert', + { defaultMessage: 'Create anomaly alert' } +); + +const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = + 'create_transaction_duration_panel'; +const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = + 'create_transaction_error_rate_panel'; +const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; + canReadAnomalies: boolean; +} + +export function AlertingPopoverAndFlyout(props: Props) { + const { canSaveAlerts, canReadAlerts, canReadAnomalies } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState(null); + + const button = ( + setPopoverOpen(true)} + > + {alertLabel} + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: transactionDurationLabel, + panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, + }, + { + name: transactionErrorRateLabel, + panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + }, + { + name: errorCountLabel, + panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + }, + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.home.alertsMenu.viewActiveAlerts', + { defaultMessage: 'View active alerts' } + ), + href: plugin.core.http.basePath.prepend( + '/app/management/insightsAndAlerting/triggersActions/alerts' + ), + icon: 'tableOfContents', + }, + ] + : []), + ], + }, + + // transaction duration panel + { + id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, + title: transactionDurationLabel, + items: [ + // anomaly alerts + ...(canReadAnomalies + ? [ + { + name: createAnomalyAlertAlertLabel, + onClick: () => { + setAlertType(AlertType.TransactionDurationAnomaly); + setPopoverOpen(false); + }, + }, + ] + : []), + ], + }, + + // transaction error rate panel + { + id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + title: transactionErrorRateLabel, + items: [ + // threshold alerts + { + name: createThresholdAlertLabel, + onClick: () => { + setAlertType(AlertType.TransactionErrorRate); + setPopoverOpen(false); + }, + }, + ], + }, + + // error alerts panel + { + id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + title: errorCountLabel, + items: [ + { + name: createThresholdAlertLabel, + onClick: () => { + setAlertType(AlertType.ErrorCount); + setPopoverOpen(false); + }, + }, + ], + }, + ]; + + return ( + <> + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + { + if (!visible) { + setAlertType(null); + } + }} + /> + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index b2f15dbb11341..446f7b978a434 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -15,17 +15,19 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { $ElementType } from 'utility-types'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { getAlertingCapabilities } from '../../alerting/get_alert_capabilities'; import { ApmHeader } from '../../shared/ApmHeader'; import { EuiTabLink } from '../../shared/EuiTabLink'; +import { AnomalyDetectionSetupLink } from '../../shared/Links/apm/AnomalyDetectionSetupLink'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { ServiceOverviewLink } from '../../shared/Links/apm/ServiceOverviewLink'; import { SettingsLink } from '../../shared/Links/apm/SettingsLink'; -import { AnomalyDetectionSetupLink } from '../../shared/Links/apm/AnomalyDetectionSetupLink'; import { TraceOverviewLink } from '../../shared/Links/apm/TraceOverviewLink'; import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; import { ServiceMap } from '../ServiceMap'; import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; +import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; function getHomeTabs({ serviceMapEnabled = true, @@ -83,13 +85,21 @@ interface Props { } export function Home({ tab }: Props) { - const { config, core } = useApmPluginContext(); - const canAccessML = !!core.application.capabilities.ml?.canAccessML; + const { config, core, plugins } = useApmPluginContext(); + const capabilities = core.application.capabilities; + const canAccessML = !!capabilities.ml?.canAccessML; const homeTabs = getHomeTabs(config); const selectedTab = homeTabs.find( (homeTab) => homeTab.name === tab ) as $ElementType; + const { + isAlertingAvailable, + canReadAlerts, + canSaveAlerts, + canReadAnomalies, + } = getAlertingCapabilities(plugins, core.application.capabilities); + return (
@@ -106,6 +116,15 @@ export function Home({ tab }: Props) { + {isAlertingAvailable && ( + + + + )} {canAccessML && ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/alerting_popover_flyout/index.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/alerting_popover_flyout/index.tsx index c11bfdeae945b..3a8d24f0a8b02 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/alerting_popover_flyout/index.tsx @@ -7,14 +7,14 @@ import { EuiButtonEmpty, EuiContextMenu, - EuiPopover, EuiContextMenuPanelDescriptor, + EuiPopover, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { AlertType } from '../../../../../common/alert_types'; -import { AlertingFlyout } from './AlertingFlyout'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; +import { AlertingFlyout } from '../../../alerting/AlertingFlyout'; const alertLabel = i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.alerts', @@ -53,7 +53,7 @@ interface Props { canReadAnomalies: boolean; } -export function AlertIntegrations(props: Props) { +export function AlertingPopoverAndFlyout(props: Props) { const { canSaveAlerts, canReadAlerts, canReadAnomalies } = props; const plugin = useApmPluginContext(); diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx index 67c4a7c4cde1b..8825702cafd51 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -14,8 +14,9 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { getAlertingCapabilities } from '../../alerting/get_alert_capabilities'; import { ApmHeader } from '../../shared/ApmHeader'; -import { AlertIntegrations } from './AlertIntegrations'; +import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { ServiceDetailTabs } from './ServiceDetailTabs'; interface Props extends RouteComponentProps<{ serviceName: string }> { @@ -23,20 +24,15 @@ interface Props extends RouteComponentProps<{ serviceName: string }> { } export function ServiceDetails({ match, tab }: Props) { - const plugin = useApmPluginContext(); + const { core, plugins } = useApmPluginContext(); const { serviceName } = match.params; - const capabilities = plugin.core.application.capabilities; - const canReadAlerts = !!capabilities.apm['alerting:show']; - const canSaveAlerts = !!capabilities.apm['alerting:save']; - const isAlertingPluginEnabled = 'alerts' in plugin.plugins; - const isAlertingAvailable = - isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts); - const isMlPluginEnabled = 'ml' in plugin.plugins; - const canReadAnomalies = !!( - isMlPluginEnabled && - capabilities.ml.canAccessML && - capabilities.ml.canGetJobs - ); + + const { + isAlertingAvailable, + canReadAlerts, + canSaveAlerts, + canReadAnomalies, + } = getAlertingCapabilities(plugins, core.application.capabilities); const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', { defaultMessage: 'Add data', @@ -53,7 +49,7 @@ export function ServiceDetails({ match, tab }: Props) { {isAlertingAvailable && ( - = (source: Rx.Observable) => Rx.Observable; +const pipeClosure = (fn: Operator): Operator => { + return (source: Rx.Observable) => { + return Rx.defer(() => fn(source)); + }; +}; +const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( + pipeClosure((source$) => { + return source$.pipe(map((i) => i)); + }), + toArray() +) as unknown) as Observable; + +describe('Error count alert', () => { + it("doesn't send an alert when error count is less than threshold", async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerErrorCountAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 0, + }, + }, + })), + alertInstanceFactory: jest.fn(), + }; + const params = { threshold: 1 }; + + await alertExecutor!({ services, params }); + expect(services.alertInstanceFactory).not.toBeCalled(); + }); + + it('sends alerts with service name and environment', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerErrorCountAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 2, + }, + }, + aggregations: { + services: { + buckets: [ + { + key: 'foo', + environments: { + buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }], + }, + }, + { + key: 'bar', + environments: { + buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }], + }, + }, + ], + }, + }, + })), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 1 }; + + await alertExecutor!({ services, params }); + [ + 'apm.error_rate_foo_env-foo', + 'apm.error_rate_foo_env-foo-2', + 'apm.error_rate_bar_env-bar', + 'apm.error_rate_bar_env-bar-2', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 1, + triggerValue: 2, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 1, + triggerValue: 2, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + threshold: 1, + triggerValue: 2, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar-2', + threshold: 1, + triggerValue: 2, + }); + }); + it('sends alerts with service name', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerErrorCountAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 2, + }, + }, + aggregations: { + services: { + buckets: [ + { + key: 'foo', + }, + { + key: 'bar', + }, + ], + }, + }, + })), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 1 }; + + await alertExecutor!({ services, params }); + ['apm.error_rate_foo', 'apm.error_rate_bar'].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: undefined, + threshold: 1, + triggerValue: 2, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: undefined, + threshold: 1, + triggerValue: 2, + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 5455cd9f6a495..26e4a5e84b995 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -5,22 +5,21 @@ */ import { schema } from '@kbn/config-schema'; +import { isEmpty } from 'lodash'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { APMConfig } from '../..'; +import { AlertingPlugin } from '../../../../alerts/server'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; -import { - ESSearchResponse, - ESSearchRequest, -} from '../../../typings/elasticsearch'; import { PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; -import { AlertingPlugin } from '../../../../alerts/server'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; -import { APMConfig } from '../..'; import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { @@ -32,7 +31,7 @@ const paramsSchema = schema.object({ windowSize: schema.number(), windowUnit: schema.string(), threshold: schema.number(), - serviceName: schema.string(), + serviceName: schema.maybe(schema.string()), environment: schema.string(), }); @@ -83,30 +82,74 @@ export function registerErrorCountAlertType({ }, }, { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, - { term: { [SERVICE_NAME]: alertParams.serviceName } }, + ...(alertParams.serviceName + ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] + : []), ...getEnvironmentUiFilterES(alertParams.environment), ], }, }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: 50, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + }, + }, + }, }, }; const response: ESSearchResponse< unknown, - ESSearchRequest + typeof searchParams > = await services.callCluster('search', searchParams); const errorCount = response.hits.total.value; if (errorCount > alertParams.threshold) { - const alertInstance = services.alertInstanceFactory( - AlertType.ErrorCount - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - serviceName: alertParams.serviceName, - environment: alertParams.environment, - threshold: alertParams.threshold, - triggerValue: errorCount, + function scheduleAction({ + serviceName, + environment, + }: { + serviceName: string; + environment?: string; + }) { + const alertInstanceName = [ + AlertType.ErrorCount, + serviceName, + environment, + ] + .filter((name) => name) + .join('_'); + + const alertInstance = services.alertInstanceFactory( + alertInstanceName + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + environment, + threshold: alertParams.threshold, + triggerValue: errorCount, + }); + } + response.aggregations?.services.buckets.forEach((serviceBucket) => { + const serviceName = serviceBucket.key as string; + if (isEmpty(serviceBucket.environments?.buckets)) { + scheduleAction({ serviceName }); + } else { + serviceBucket.environments.buckets.forEach((envBucket) => { + const environment = envBucket.key as string; + scheduleAction({ serviceName, environment }); + }); + } }); } }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts new file mode 100644 index 0000000000000..6e97262dd77bb --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -0,0 +1,326 @@ +/* + * 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 { Observable } from 'rxjs'; +import * as Rx from 'rxjs'; +import { toArray, map } from 'rxjs/operators'; +import { AlertingPlugin } from '../../../../alerts/server'; +import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { APMConfig } from '../..'; +import { ANOMALY_SEVERITY } from '../../../../ml/common'; +import { Job, MlPluginSetup } from '../../../../ml/server'; +import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; + +type Operator = (source: Rx.Observable) => Rx.Observable; +const pipeClosure = (fn: Operator): Operator => { + return (source: Rx.Observable) => { + return Rx.defer(() => fn(source)); + }; +}; +const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( + pipeClosure((source$) => { + return source$.pipe(map((i) => i)); + }), + toArray() +) as unknown) as Observable; + +describe('Transaction duration anomaly alert', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe("doesn't send alert", () => { + it('ml is not defined', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerTransactionDurationAnomalyAlertType({ + alerts, + ml: undefined, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const services = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(), + }; + const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; + + await alertExecutor!({ services, params }); + expect(services.callCluster).not.toHaveBeenCalled(); + expect(services.alertInstanceFactory).not.toHaveBeenCalled(); + }); + + it('ml jobs are not available', async () => { + jest + .spyOn(GetServiceAnomalies, 'getMLJobs') + .mockReturnValue(Promise.resolve([])); + + let alertExecutor: any; + + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + const ml = ({ + mlSystemProvider: () => ({ mlAnomalySearch: jest.fn() }), + anomalyDetectorsProvider: jest.fn(), + } as unknown) as MlPluginSetup; + + registerTransactionDurationAnomalyAlertType({ + alerts, + ml, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const services = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(), + }; + const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; + + await alertExecutor!({ services, params }); + expect(services.callCluster).not.toHaveBeenCalled(); + expect(services.alertInstanceFactory).not.toHaveBeenCalled(); + }); + + it('anomaly is less than threshold', async () => { + jest + .spyOn(GetServiceAnomalies, 'getMLJobs') + .mockReturnValue( + Promise.resolve([{ job_id: '1' }, { job_id: '2' }] as Job[]) + ); + + let alertExecutor: any; + + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + const ml = ({ + mlSystemProvider: () => ({ + mlAnomalySearch: () => ({ + hits: { total: { value: 0 } }, + }), + }), + anomalyDetectorsProvider: jest.fn(), + } as unknown) as MlPluginSetup; + + registerTransactionDurationAnomalyAlertType({ + alerts, + ml, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const services = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(), + }; + const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; + + await alertExecutor!({ services, params }); + expect(services.callCluster).not.toHaveBeenCalled(); + expect(services.alertInstanceFactory).not.toHaveBeenCalled(); + }); + }); + + describe('sends alert', () => { + it('with service name, environment and transaction type', async () => { + jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( + Promise.resolve([ + { + job_id: '1', + custom_settings: { + job_tags: { + environment: 'production', + }, + }, + } as unknown, + { + job_id: '2', + custom_settings: { + job_tags: { + environment: 'production', + }, + }, + } as unknown, + ] as Job[]) + ); + + let alertExecutor: any; + + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + const ml = ({ + mlSystemProvider: () => ({ + mlAnomalySearch: () => ({ + hits: { total: { value: 2 } }, + aggregations: { + services: { + buckets: [ + { + key: 'foo', + transaction_types: { + buckets: [{ key: 'type-foo' }], + }, + }, + { + key: 'bar', + transaction_types: { + buckets: [{ key: 'type-bar' }], + }, + }, + ], + }, + }, + }), + }), + anomalyDetectorsProvider: jest.fn(), + } as unknown) as MlPluginSetup; + + registerTransactionDurationAnomalyAlertType({ + alerts, + ml, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; + + await alertExecutor!({ services, params }); + + await alertExecutor!({ services, params }); + [ + 'apm.transaction_duration_anomaly_foo_production_type-foo', + 'apm.transaction_duration_anomaly_bar_production_type-bar', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'production', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: 'type-bar', + environment: 'production', + }); + }); + + it('with service name', async () => { + jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( + Promise.resolve([ + { + job_id: '1', + custom_settings: { + job_tags: { + environment: 'production', + }, + }, + } as unknown, + { + job_id: '2', + custom_settings: { + job_tags: { + environment: 'testing', + }, + }, + } as unknown, + ] as Job[]) + ); + + let alertExecutor: any; + + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + const ml = ({ + mlSystemProvider: () => ({ + mlAnomalySearch: () => ({ + hits: { total: { value: 2 } }, + aggregations: { + services: { + buckets: [{ key: 'foo' }, { key: 'bar' }], + }, + }, + }), + }), + anomalyDetectorsProvider: jest.fn(), + } as unknown) as MlPluginSetup; + + registerTransactionDurationAnomalyAlertType({ + alerts, + ml, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR }; + + await alertExecutor!({ services, params }); + + await alertExecutor!({ services, params }); + [ + 'apm.transaction_duration_anomaly_foo_production', + 'apm.transaction_duration_anomaly_foo_testing', + 'apm.transaction_duration_anomaly_bar_production', + 'apm.transaction_duration_anomaly_bar_testing', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: undefined, + environment: 'production', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: undefined, + environment: 'production', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: undefined, + environment: 'testing', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: undefined, + environment: 'testing', + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 61cd79b672735..36b7964e8128d 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -6,6 +6,7 @@ import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; +import { isEmpty } from 'lodash'; import { ANOMALY_SEVERITY } from '../../../../ml/common'; import { KibanaRequest } from '../../../../../../src/core/server'; import { @@ -16,7 +17,7 @@ import { import { AlertingPlugin } from '../../../../alerts/server'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; -import { getMLJobIds } from '../service_map/get_service_anomalies'; +import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { @@ -26,8 +27,8 @@ interface RegisterAlertParams { } const paramsSchema = schema.object({ - serviceName: schema.string(), - transactionType: schema.string(), + serviceName: schema.maybe(schema.string()), + transactionType: schema.maybe(schema.string()), windowSize: schema.number(), windowUnit: schema.string(), environment: schema.string(), @@ -72,10 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({ const { mlAnomalySearch } = ml.mlSystemProvider(request); const anomalyDetectors = ml.anomalyDetectorsProvider(request); - const mlJobIds = await getMLJobIds( - anomalyDetectors, - alertParams.environment - ); + const mlJobs = await getMLJobs(anomalyDetectors, alertParams.environment); const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( (option) => option.type === alertParams.anomalySeverityType @@ -89,19 +87,19 @@ export function registerTransactionDurationAnomalyAlertType({ const threshold = selectedOption.threshold; - if (mlJobIds.length === 0) { + if (mlJobs.length === 0) { return {}; } const anomalySearchParams = { + terminateAfter: 1, body: { - terminateAfter: 1, size: 0, query: { bool: { filter: [ { term: { result_type: 'record' } }, - { terms: { job_id: mlJobIds } }, + { terms: { job_id: mlJobs.map((job) => job.job_id) } }, { range: { timestamp: { @@ -110,11 +108,24 @@ export function registerTransactionDurationAnomalyAlertType({ }, }, }, - { - term: { - partition_field_value: alertParams.serviceName, - }, - }, + ...(alertParams.serviceName + ? [ + { + term: { + partition_field_value: alertParams.serviceName, + }, + }, + ] + : []), + ...(alertParams.transactionType + ? [ + { + term: { + by_field_value: alertParams.transactionType, + }, + }, + ] + : []), { range: { record_score: { @@ -125,22 +136,82 @@ export function registerTransactionDurationAnomalyAlertType({ ], }, }, + aggs: { + services: { + terms: { + field: 'partition_field_value', + size: 50, + }, + aggs: { + transaction_types: { + terms: { + field: 'by_field_value', + }, + }, + }, + }, + }, }, }; const response = ((await mlAnomalySearch( anomalySearchParams - )) as unknown) as { hits: { total: { value: number } } }; + )) as unknown) as { + hits: { total: { value: number } }; + aggregations?: { + services: { + buckets: Array<{ + key: string; + transaction_types: { buckets: Array<{ key: string }> }; + }>; + }; + }; + }; + const hitCount = response.hits.total.value; if (hitCount > 0) { - const alertInstance = services.alertInstanceFactory( - AlertType.TransactionDurationAnomaly - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - serviceName: alertParams.serviceName, - transactionType: alertParams.transactionType, - environment: alertParams.environment, + function scheduleAction({ + serviceName, + environment, + transactionType, + }: { + serviceName: string; + environment?: string; + transactionType?: string; + }) { + const alertInstanceName = [ + AlertType.TransactionDurationAnomaly, + serviceName, + environment, + transactionType, + ] + .filter((name) => name) + .join('_'); + + const alertInstance = services.alertInstanceFactory( + alertInstanceName + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + environment, + transactionType, + }); + } + + mlJobs.map((job) => { + const environment = job.custom_settings?.job_tags?.environment; + response.aggregations?.services.buckets.forEach((serviceBucket) => { + const serviceName = serviceBucket.key as string; + if (isEmpty(serviceBucket.transaction_types?.buckets)) { + scheduleAction({ serviceName, environment }); + } else { + serviceBucket.transaction_types?.buckets.forEach((typeBucket) => { + const transactionType = typeBucket.key as string; + scheduleAction({ serviceName, environment, transactionType }); + }); + } + }); }); } }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts new file mode 100644 index 0000000000000..90db48f84b5d9 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts @@ -0,0 +1,289 @@ +/* + * 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 { Observable } from 'rxjs'; +import * as Rx from 'rxjs'; +import { toArray, map } from 'rxjs/operators'; +import { AlertingPlugin } from '../../../../alerts/server'; +import { APMConfig } from '../..'; +import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; + +type Operator = (source: Rx.Observable) => Rx.Observable; +const pipeClosure = (fn: Operator): Operator => { + return (source: Rx.Observable) => { + return Rx.defer(() => fn(source)); + }; +}; +const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe( + pipeClosure((source$) => { + return source$.pipe(map((i) => i)); + }), + toArray() +) as unknown) as Observable; + +describe('Transaction error rate alert', () => { + it("doesn't send an alert when rate is less than threshold", async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerTransactionErrorRateAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 0, + }, + }, + })), + alertInstanceFactory: jest.fn(), + }; + const params = { threshold: 1 }; + + await alertExecutor!({ services, params }); + expect(services.alertInstanceFactory).not.toBeCalled(); + }); + + it('sends alerts with service name, transaction type and environment', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerTransactionErrorRateAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 4, + }, + }, + aggregations: { + erroneous_transactions: { + doc_count: 2, + }, + services: { + buckets: [ + { + key: 'foo', + transaction_types: { + buckets: [ + { + key: 'type-foo', + environments: { + buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }], + }, + }, + ], + }, + }, + { + key: 'bar', + transaction_types: { + buckets: [ + { + key: 'type-bar', + environments: { + buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }], + }, + }, + ], + }, + }, + ], + }, + }, + })), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10 }; + + await alertExecutor!({ services, params }); + [ + 'apm.transaction_error_rate_foo_type-foo_env-foo', + 'apm.transaction_error_rate_foo_type-foo_env-foo-2', + 'apm.transaction_error_rate_bar_type-bar_env-bar', + 'apm.transaction_error_rate_bar_type-bar_env-bar-2', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo', + threshold: 10, + triggerValue: 50, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo-2', + threshold: 10, + triggerValue: 50, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: 'type-bar', + environment: 'env-bar', + threshold: 10, + triggerValue: 50, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: 'type-bar', + environment: 'env-bar-2', + threshold: 10, + triggerValue: 50, + }); + }); + it('sends alerts with service name and transaction type', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerTransactionErrorRateAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 4, + }, + }, + aggregations: { + erroneous_transactions: { + doc_count: 2, + }, + services: { + buckets: [ + { + key: 'foo', + transaction_types: { + buckets: [{ key: 'type-foo' }], + }, + }, + { + key: 'bar', + transaction_types: { + buckets: [{ key: 'type-bar' }], + }, + }, + ], + }, + }, + })), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10 }; + + await alertExecutor!({ services, params }); + [ + 'apm.transaction_error_rate_foo_type-foo', + 'apm.transaction_error_rate_bar_type-bar', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: undefined, + threshold: 10, + triggerValue: 50, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: 'type-bar', + environment: undefined, + threshold: 10, + triggerValue: 50, + }); + }); + + it('sends alerts with service name', async () => { + let alertExecutor: any; + const alerts = { + registerType: ({ executor }) => { + alertExecutor = executor; + }, + } as AlertingPlugin['setup']; + + registerTransactionErrorRateAlertType({ + alerts, + config$: mockedConfig$, + }); + expect(alertExecutor).toBeDefined(); + + const scheduleActions = jest.fn(); + const services = { + callCluster: jest.fn(() => ({ + hits: { + total: { + value: 4, + }, + }, + aggregations: { + erroneous_transactions: { + doc_count: 2, + }, + services: { + buckets: [{ key: 'foo' }, { key: 'bar' }], + }, + }, + })), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10 }; + + await alertExecutor!({ services, params }); + [ + 'apm.transaction_error_rate_foo', + 'apm.transaction_error_rate_bar', + ].forEach((instanceName) => + expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: undefined, + environment: undefined, + threshold: 10, + triggerValue: 50, + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + transactionType: undefined, + environment: undefined, + threshold: 10, + triggerValue: 50, + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index a6ed40fc15ec6..e14360029e5dd 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; +import { isEmpty } from 'lodash'; import { ProcessorEvent } from '../../../common/processor_event'; import { EventOutcome } from '../../../common/event_outcome'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; @@ -16,6 +17,7 @@ import { SERVICE_NAME, TRANSACTION_TYPE, EVENT_OUTCOME, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; @@ -32,8 +34,8 @@ const paramsSchema = schema.object({ windowSize: schema.number(), windowUnit: schema.string(), threshold: schema.number(), - transactionType: schema.string(), - serviceName: schema.string(), + transactionType: schema.maybe(schema.string()), + serviceName: schema.maybe(schema.string()), environment: schema.string(), }); @@ -84,8 +86,18 @@ export function registerTransactionErrorRateAlertType({ }, }, { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - { term: { [SERVICE_NAME]: alertParams.serviceName } }, - { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...(alertParams.serviceName + ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] + : []), + ...(alertParams.transactionType + ? [ + { + term: { + [TRANSACTION_TYPE]: alertParams.transactionType, + }, + }, + ] + : []), ...getEnvironmentUiFilterES(alertParams.environment), ], }, @@ -94,6 +106,24 @@ export function registerTransactionErrorRateAlertType({ erroneous_transactions: { filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, }, + services: { + terms: { + field: SERVICE_NAME, + size: 50, + }, + aggs: { + transaction_types: { + terms: { field: TRANSACTION_TYPE }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + }, + }, + }, + }, }, }, }; @@ -114,16 +144,53 @@ export function registerTransactionErrorRateAlertType({ (errornousTransactionsCount / totalTransactionCount) * 100; if (transactionErrorRate > alertParams.threshold) { - const alertInstance = services.alertInstanceFactory( - AlertType.TransactionErrorRate - ); + function scheduleAction({ + serviceName, + environment, + transactionType, + }: { + serviceName: string; + environment?: string; + transactionType?: string; + }) { + const alertInstanceName = [ + AlertType.TransactionErrorRate, + serviceName, + transactionType, + environment, + ] + .filter((name) => name) + .join('_'); + + const alertInstance = services.alertInstanceFactory( + alertInstanceName + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName, + transactionType, + environment, + threshold: alertParams.threshold, + triggerValue: transactionErrorRate, + }); + } - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - serviceName: alertParams.serviceName, - transactionType: alertParams.transactionType, - environment: alertParams.environment, - threshold: alertParams.threshold, - triggerValue: transactionErrorRate, + response.aggregations?.services.buckets.forEach((serviceBucket) => { + const serviceName = serviceBucket.key as string; + if (isEmpty(serviceBucket.transaction_types?.buckets)) { + scheduleAction({ serviceName }); + } else { + serviceBucket.transaction_types.buckets.forEach((typeBucket) => { + const transactionType = typeBucket.key as string; + if (isEmpty(typeBucket.environments?.buckets)) { + scheduleAction({ serviceName, transactionType }); + } else { + typeBucket.environments.buckets.forEach((envBucket) => { + const environment = envBucket.key as string; + scheduleAction({ serviceName, transactionType, environment }); + }); + } + }); + } }); } }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index 44c0c96142096..895fc70d76af1 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -180,7 +180,7 @@ function transformResponseToServiceAnomalies( return serviceAnomaliesMap; } -export async function getMLJobIds( +export async function getMLJobs( anomalyDetectors: ReturnType, environment?: string ) { @@ -198,7 +198,15 @@ export async function getMLJobIds( if (!matchingMLJob) { return []; } - return [matchingMLJob.job_id]; + return [matchingMLJob]; } + return mlJobs; +} + +export async function getMLJobIds( + anomalyDetectors: ReturnType, + environment?: string +) { + const mlJobs = await getMLJobs(anomalyDetectors, environment); return mlJobs.map((job) => job.job_id); } From 53d49381c84df5f7eeae5d912917324d3c4333b4 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 28 Sep 2020 16:14:30 +0300 Subject: [PATCH 2/6] Implement tagcloud renderer (#77910) * Implement toExpressionAst for tagcloud * Implement tagcloud vis renderer * Use resize observer * Use common no data message * Update build_pipeline.test * Update tag cloud tests * Revert "Use common no data message" This reverts commit fddf019575f4e22aced1ef1f262d8b499d0e8da7. * Update interpreter functional tests * Add tests for toExpressionAst fn * Use throttled chart update * Update renderer Co-authored-by: Elastic Machine --- .../__snapshots__/tag_cloud_fn.test.ts.snap | 31 ++- .../public/__snapshots__/to_ast.test.ts.snap | 171 ++++++++++++++ .../vis_type_tagcloud/public/_tag_cloud.scss | 14 -- .../public/components/label.js | 2 +- .../public/components/tag_cloud.scss | 26 +++ .../public/components/tag_cloud_chart.tsx | 84 +++++++ .../components/tag_cloud_visualization.js | 216 +++++++++--------- .../tag_cloud_visualization.test.js | 77 ++----- .../vis_type_tagcloud/public/index.scss | 8 - .../vis_type_tagcloud/public/plugin.ts | 10 +- .../vis_type_tagcloud/public/tag_cloud_fn.ts | 26 +-- .../public/tag_cloud_type.ts | 14 +- .../public/tag_cloud_vis_renderer.tsx | 54 +++++ .../vis_type_tagcloud/public/to_ast.test.ts | 84 +++++++ .../vis_type_tagcloud/public/to_ast.ts | 60 +++++ src/plugins/visualizations/public/index.ts | 2 +- .../__snapshots__/build_pipeline.test.ts.snap | 6 - .../public/legacy/build_pipeline.test.ts | 22 -- .../public/legacy/build_pipeline.ts | 60 +---- src/plugins/visualizations/public/vis.ts | 4 +- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- .../snapshots/session/partial_test_1.json | 2 +- .../snapshots/session/tagcloud_all_data.json | 2 +- .../snapshots/session/tagcloud_fontsize.json | 2 +- .../session/tagcloud_metric_data.json | 2 +- .../snapshots/session/tagcloud_options.json | 2 +- 30 files changed, 674 insertions(+), 317 deletions(-) create mode 100644 src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap delete mode 100644 src/plugins/vis_type_tagcloud/public/_tag_cloud.scss create mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss create mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx delete mode 100644 src/plugins/vis_type_tagcloud/public/index.scss create mode 100644 src/plugins/vis_type_tagcloud/public/tag_cloud_vis_renderer.tsx create mode 100644 src/plugins/vis_type_tagcloud/public/to_ast.test.ts create mode 100644 src/plugins/vis_type_tagcloud/public/to_ast.ts diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap index 8e28be33515f7..debc7ab27c632 100644 --- a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap @@ -2,25 +2,9 @@ exports[`interpreter/functions#tagcloud returns an object with the correct structure 1`] = ` Object { - "as": "visualization", + "as": "tagloud_vis", "type": "render", "value": Object { - "params": Object { - "listenOnChange": true, - }, - "visConfig": Object { - "maxFontSize": 72, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - }, - }, - "minFontSize": 18, - "orientation": "single", - "scale": "linear", - "showLabel": true, - }, "visData": Object { "columns": Array [ Object { @@ -35,6 +19,19 @@ Object { ], "type": "kibana_datatable", }, + "visParams": Object { + "maxFontSize": 72, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + }, + }, + "minFontSize": 18, + "orientation": "single", + "scale": "linear", + "showLabel": true, + }, "visType": "tagcloud", }, } diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 0000000000000..d64bdfb1f46f9 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[]", + ], + "includeFormatHints": Array [ + false, + ], + "index": Array [ + "123", + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "bucket": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\"}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "maxFontSize": Array [ + 15, + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "minFontSize": Array [ + 5, + ], + "orientation": Array [ + "single", + ], + "scale": Array [ + "linear", + ], + "showLabel": Array [ + true, + ], + }, + "function": "tagcloud", + "type": "function", + }, + ], + "type": "expression", +} +`; + +exports[`tagcloud vis toExpressionAst function should match snapshot without params 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[]", + ], + "includeFormatHints": Array [ + false, + ], + "index": Array [ + "123", + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "bucket": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\"}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "showLabel": Array [ + false, + ], + }, + "function": "tagcloud", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_tagcloud/public/_tag_cloud.scss b/src/plugins/vis_type_tagcloud/public/_tag_cloud.scss deleted file mode 100644 index 08901bebc0349..0000000000000 --- a/src/plugins/vis_type_tagcloud/public/_tag_cloud.scss +++ /dev/null @@ -1,14 +0,0 @@ -.tgcVis { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; -} - -.tgcVisLabel { - width: 100%; - text-align: center; - font-weight: $euiFontWeightBold; -} diff --git a/src/plugins/vis_type_tagcloud/public/components/label.js b/src/plugins/vis_type_tagcloud/public/components/label.js index 168ec4b270fde..88b3c2f851138 100644 --- a/src/plugins/vis_type_tagcloud/public/components/label.js +++ b/src/plugins/vis_type_tagcloud/public/components/label.js @@ -28,7 +28,7 @@ export class Label extends Component { render() { return (
{this.state.label} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss new file mode 100644 index 0000000000000..37867f1ed1c17 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss @@ -0,0 +1,26 @@ +// Prefix all styles with "tgc" to avoid conflicts. +// Examples +// tgcChart +// tgcChart__legend +// tgcChart__legend--small +// tgcChart__legend-isLoading + +.tgcChart__container, .tgcChart__wrapper { + flex: 1 1 0; + display: flex; +} + +.tgcChart { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +.tgcChart__label { + width: 100%; + text-align: center; + font-weight: $euiFontWeightBold; +} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx new file mode 100644 index 0000000000000..18a09ec9f4969 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useMemo, useRef } from 'react'; +import { EuiResizeObserver } from '@elastic/eui'; +import { throttle } from 'lodash'; + +import { TagCloudVisDependencies } from '../plugin'; +import { TagCloudVisRenderValue } from '../tag_cloud_fn'; +// @ts-ignore +import { TagCloudVisualization } from './tag_cloud_visualization'; + +import './tag_cloud.scss'; + +type TagCloudChartProps = TagCloudVisDependencies & + TagCloudVisRenderValue & { + fireEvent: (event: any) => void; + renderComplete: () => void; + }; + +export const TagCloudChart = ({ + colors, + visData, + visParams, + fireEvent, + renderComplete, +}: TagCloudChartProps) => { + const chartDiv = useRef(null); + const visController = useRef(null); + + useEffect(() => { + visController.current = new TagCloudVisualization(chartDiv.current, colors, fireEvent); + return () => { + visController.current.destroy(); + visController.current = null; + }; + }, [colors, fireEvent]); + + useEffect(() => { + if (visController.current) { + visController.current.render(visData, visParams).then(renderComplete); + } + }, [visData, visParams, renderComplete]); + + const updateChartSize = useMemo( + () => + throttle(() => { + if (visController.current) { + visController.current.render().then(renderComplete); + } + }, 300), + [renderComplete] + ); + + return ( + + {(resizeRef) => ( +
+
+
+ )} + + ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TagCloudChart as default }; diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js index e43b3bdc747ab..5ec22d2c6a4d9 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js @@ -32,126 +32,138 @@ import d3 from 'd3'; const MAX_TAG_COUNT = 200; -export function createTagCloudVisualization({ colors }) { - const colorScale = d3.scale.ordinal().range(colors.seedColors); - return class TagCloudVisualization { - constructor(node, vis) { - this._containerNode = node; - - const cloudRelativeContainer = document.createElement('div'); - cloudRelativeContainer.classList.add('tgcVis'); - cloudRelativeContainer.setAttribute('style', 'position: relative'); - const cloudContainer = document.createElement('div'); - cloudContainer.classList.add('tgcVis'); - cloudContainer.setAttribute('data-test-subj', 'tagCloudVisualization'); - this._containerNode.classList.add('visChart--vertical'); - cloudRelativeContainer.appendChild(cloudContainer); - this._containerNode.appendChild(cloudRelativeContainer); - - this._vis = vis; - this._truncated = false; - this._tagCloud = new TagCloud(cloudContainer, colorScale); - this._tagCloud.on('select', (event) => { - if (!this._visParams.bucket) { - return; - } - this._vis.API.events.filter({ - table: event.meta.data, - column: 0, - row: event.meta.rowIndex, - }); - }); - this._renderComplete$ = Rx.fromEvent(this._tagCloud, 'renderComplete'); - - this._feedbackNode = document.createElement('div'); - this._containerNode.appendChild(this._feedbackNode); - this._feedbackMessage = React.createRef(); - render( - - - , - this._feedbackNode - ); - - this._labelNode = document.createElement('div'); - this._containerNode.appendChild(this._labelNode); - this._label = React.createRef(); - render(