diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 6f238b48d9465..6cfd18d0c1cba 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -91,24 +91,34 @@ export const apm: LegacyPluginInitializer = kibana => { navLinkId: 'apm', app: ['apm', 'kibana'], catalogue: ['apm'], + // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { - api: ['apm', 'apm_write'], + api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show', 'save'] + ui: [ + 'show', + 'save', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] }, read: { - api: ['apm'], + api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show'] + ui: ['show', 'alerting:show', 'actions:show'] } } }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx index c2c396d5b8951..68acaee4abe5d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx @@ -11,9 +11,9 @@ import { APMIndicesPermission } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import { expectTextsInDocument, - MockApmPluginContextWrapper, expectTextsNotInDocument } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx index 68d19a41f33a4..a09482d663f65 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx @@ -9,25 +9,7 @@ import React from 'react'; import { mockMoment, toJson } from '../../../../../utils/testHelpers'; import { ErrorGroupList } from '../index'; import props from './props.json'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { - useUiFilters, - UrlParamsContext -} from '../../../../../context/UrlParamsContext'; - -const mockRefreshTimeRange = jest.fn(); -const MockUrlParamsProvider: React.FC<{ - params?: IUrlParams; -}> = ({ params = props.urlParams, children }) => ( - -); +import { MockUrlParamsContextProvider } from '../../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; describe('ErrorGroupOverview -> List', () => { beforeAll(() => { @@ -37,9 +19,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render empty state', () => { const storeState = {}; const wrapper = mount( - + - , + , storeState ); @@ -48,9 +30,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render with data', () => { const wrapper = mount( - + - + ); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 92198220628d1..431a6c71b103b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -1,11 +1,4 @@ { - "urlParams": { - "page": 0, - "serviceName": "opbeans-python", - "transactionType": "request", - "start": "2018-01-10T09:51:41.050Z", - "end": "2018-01-10T10:06:41.050Z" - }, "items": [ { "message": "About to blow up!", diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx index 711290942cea1..ab4ca1dfbb49d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('Home component', () => { it('should render services', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 5bf8cb8271fa4..e610f3b84899b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -8,12 +8,12 @@ import { mount } from 'enzyme'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; -import { - mockApmPluginContextValue, - MockApmPluginContextWrapper -} from '../../../utils/testHelpers'; import { routes } from './route_config'; import { UpdateBreadcrumbs } from './UpdateBreadcrumbs'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx new file mode 100644 index 0000000000000..7e8d057a7be6c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx @@ -0,0 +1,30 @@ +/* + * 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 React from 'react'; +import { AlertType } from '../../../../../../../../../plugins/apm/common/alert_types'; +import { AlertAdd } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + +type AlertAddProps = React.ComponentProps; + +interface Props { + addFlyoutVisible: AlertAddProps['addFlyoutVisible']; + setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; + alertType: AlertType | null; +} + +export function AlertingFlyout(props: Props) { + const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; + + return alertType ? ( + + ) : null; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx new file mode 100644 index 0000000000000..92b325ab00d35 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -0,0 +1,146 @@ +/* + * 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, + EuiPopover, + EuiContextMenuPanelDescriptor +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../../../../plugins/apm/common/alert_types'; +import { AlertingFlyout } from './AlertingFlyout'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; + +const alertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.alerts', + { + defaultMessage: 'Alerts' + } +); + +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', + { + defaultMessage: 'Create threshold alert' + } +); + +const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; +} + +export function AlertIntegrations(props: Props) { + const { canSaveAlerts, canReadAlerts } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState(null); + + const button = ( + setPopoverOpen(true)} + > + {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { + defaultMessage: 'Alerts' + })} + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_ALERT_PANEL_ID, + icon: 'bell' + } + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', + { + defaultMessage: 'View active alerts' + } + ), + href: plugin.core.http.basePath.prepend( + '/app/kibana#/management/kibana/triggersActions/alerts' + ), + icon: 'tableOfContents' + } + ] + : []) + ] + }, + { + id: CREATE_THRESHOLD_ALERT_PANEL_ID, + title: createThresholdAlertLabel, + items: [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', + { + defaultMessage: 'Transaction duration' + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDuration); + } + }, + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorRate', + { + defaultMessage: 'Error rate' + } + ), + onClick: () => { + setAlertType(AlertType.ErrorRate); + } + } + ] + } + ]; + + return ( + <> + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + { + if (!visible) { + setAlertType(null); + } + }} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx index ac7dfd49d4f3d..77ae67b71e1b6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -10,15 +10,27 @@ import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { AlertIntegrations } from './AlertIntegrations'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; interface Props { tab: React.ComponentProps['tab']; } export function ServiceDetails({ tab }: Props) { + const plugin = useApmPluginContext(); const { urlParams } = useUrlParams(); const { serviceName } = urlParams; + const canReadAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:show' + ]; + const canSaveAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:save' + ]; + + const isAlertingAvailable = canReadAlerts || canSaveAlerts; + return (
@@ -31,6 +43,14 @@ export function ServiceDetails({ tab }: Props) { + {isAlertingAvailable && ( + + + + )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 7a066b520cc3b..46754c8c7cb6b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -194,6 +194,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) const height = 640; const width = 1340; const serviceName = undefined; // global service map + return ( { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index 241f272b54a1d..b286d33ca74e9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -11,11 +11,11 @@ import * as urlParamsHooks from '../../../../hooks/useUrlParams'; import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock'; +import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; import { MockApmPluginContextWrapper, mockApmPluginContextValue -} from '../../../../utils/testHelpers'; -import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; +} from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index fd71bf9709ce9..272c4b3add415 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -7,8 +7,8 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; import { ApmIndices } from '.'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('ApmIndices', () => { it('should not get stuck in infinite loop', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 7c39356189891..b5bee5a5a1ebb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -13,11 +13,11 @@ import * as hooks from '../../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../../context/LicenseContext'; import { CustomLinkOverview } from '.'; import { - MockApmPluginContextWrapper, expectTextsInDocument, expectTextsNotInDocument } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const data = [ { diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx index fe58fc39c6cfa..b8d6d9818eb2c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TraceLink } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import * as urlParamsHooks from '../../../../hooks/useUrlParams'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx index 882682f1f6760..22cbeee5c6b7c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx @@ -22,7 +22,7 @@ import * as useFetcherHook from '../../../../hooks/useFetcher'; import { fromQuery } from '../../../shared/Links/url_helpers'; import { Router } from 'react-router-dom'; import { UrlParamsProvider } from '../../../../context/UrlParamsContext'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..4ef8de7c2b208 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx @@ -0,0 +1,26 @@ +/* + * 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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ErrorRateAlertTrigger } from '.'; + +storiesOf('app/ErrorRateAlertTrigger', module).add('example', props => { + const params = { + threshold: 2, + window: '5m' + }; + + return ( +
+ undefined} + setAlertProperty={() => undefined} + /> +
+ ); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..6d0a2b96092a1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx @@ -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 React from 'react'; +import { EuiFieldNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG } from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +export interface ErrorRateAlertTriggerParams { + windowSize: number; + windowUnit: string; + threshold: number; +} + +interface Props { + alertParams: ErrorRateAlertTriggerParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function ErrorRateAlertTrigger(props: Props) { + const { setAlertParams, setAlertProperty, alertParams } = props; + + const defaults = { + threshold: 25, + windowSize: 1, + windowUnit: 'm' + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + + setAlertParams('threshold', parseInt(e.target.value, 10)) + } + compressed + append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { + defaultMessage: 'errors' + })} + /> + , + + setAlertParams('windowSize', windowSize) + } + onChangeWindowUnit={windowUnit => + setAlertParams('windowUnit', windowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx index 0c60d523b8f3f..258788252379a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx index ee66636d88ba9..0059b7b8fb4b3 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx @@ -10,9 +10,9 @@ import { SpanMetadata } from '..'; import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx index f426074fbef80..3d78f36db9786 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx index 979b9118a7534..96202525c8661 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx @@ -7,11 +7,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MetadataTable } from '..'; -import { - expectTextsInDocument, - MockApmPluginContextWrapper -} from '../../../../utils/testHelpers'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; import { SectionsWithRows } from '../helper'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx new file mode 100644 index 0000000000000..1abdb94c8313e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiExpression, EuiPopover } from '@elastic/eui'; + +interface Props { + title: string; + value: string; + children?: React.ReactNode; +} + +export const PopoverExpression = (props: Props) => { + const { title, value, children } = props; + + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(false)} + button={ + setPopoverOpen(true)} + /> + } + > + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx new file mode 100644 index 0000000000000..98391b277caf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx @@ -0,0 +1,61 @@ +/* + * 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 React, { useEffect } from 'react'; +import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks/useUrlParams'; + +interface Props { + alertTypeName: string; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + defaults: Record; + fields: React.ReactNode[]; +} + +export function ServiceAlertTrigger(props: Props) { + const { urlParams } = useUrlParams(); + + const { + fields, + setAlertParams, + setAlertProperty, + alertTypeName, + defaults + } = props; + + const params: Record = { + ...defaults, + serviceName: urlParams.serviceName! + }; + + 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() + ]); + Object.keys(params).forEach(key => { + setAlertParams(key, params[key]); + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + + + {fields.map((field, index) => ( + + {field} + + ))} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 9094662e34914..560884aec554a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -10,13 +10,13 @@ import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; import { - MockApmPluginContextWrapper, expectTextsNotInDocument, expectTextsInDocument } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; import { License } from '../../../../../../../../plugins/licensing/common/license'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderTransaction = async (transaction: Record) => { const rendered = render( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..a8f834103e6c1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -0,0 +1,50 @@ +/* + * 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 { cloneDeep, merge } from 'lodash'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { TransactionDurationAlertTrigger } from '.'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; + +storiesOf('app/TransactionDurationAlertTrigger', module).add( + 'example', + context => { + const params = { + threshold: 1500, + aggregationType: 'avg' as const, + window: '5m' + }; + + const contextMock = (merge(cloneDeep(mockApmPluginContextValue), { + core: { + http: { + get: () => { + return Promise.resolve({ transactionTypes: ['request'] }); + } + } + } + }) as unknown) as ApmPluginContextValue; + + return ( +
+ + + undefined} + setAlertProperty={() => undefined} + /> + + +
+ ); + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx new file mode 100644 index 0000000000000..cdc7c30089b4f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx @@ -0,0 +1,149 @@ +/* + * 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 React from 'react'; +import { map } from 'lodash'; +import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { + TRANSACTION_ALERT_AGGREGATION_TYPES, + ALERT_TYPES_CONFIG +} from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +interface Params { + windowSize: number; + windowUnit: string; + threshold: number; + aggregationType: 'avg' | '95th' | '99th'; + serviceName: string; + transactionType: string; +} + +interface Props { + alertParams: Params; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionDurationAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + + const { urlParams } = useUrlParams(); + + const transactionTypes = useServiceTransactionTypes(urlParams); + + if (!transactionTypes.length) { + return null; + } + + const defaults = { + threshold: 1500, + aggregationType: 'avg', + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypes[0] + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + { + return { + text: key, + value: key + }; + })} + onChange={e => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + , + + { + return { + text: label, + value: key + }; + })} + onChange={e => + setAlertParams( + 'aggregationType', + e.target.value as Params['aggregationType'] + ) + } + compressed + /> + , + + setAlertParams('threshold', e.target.value)} + append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { + defaultMessage: 'ms' + })} + compressed + /> + , + + setAlertParams('windowSize', timeWindowSize) + } + onChangeWindowUnit={timeWindowUnit => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx index 6d3e29ec09985..9f112475a4a78 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { BrowserLineChart } from './BrowserLineChart'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('BrowserLineChart', () => { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx new file mode 100644 index 0000000000000..8775dc98c3e1a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -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 React from 'react'; +import { ApmPluginContext, ApmPluginContextValue } from '.'; +import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { ConfigSchema } from '../../new-platform/plugin'; + +const mockCore = { + chrome: { + setBreadcrumbs: () => {} + }, + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + }, + notifications: { + toasts: { + addWarning: () => {}, + addDanger: () => {} + } + } +}; + +const mockConfig: ConfigSchema = { + indexPatternTitle: 'apm-*', + serviceMapEnabled: true, + ui: { + enabled: false + } +}; + +export const mockApmPluginContextValue = { + config: mockConfig, + core: mockCore, + packageInfo: { version: '0' }, + plugins: {} +}; + +export function MockApmPluginContextWrapper({ + children, + value = {} as ApmPluginContextValue +}: { + children?: React.ReactNode; + value?: ApmPluginContextValue; +}) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } + return ( + + {children} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx similarity index 89% rename from x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx rename to x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx index 7a9aaa6dfb920..d8934ba4b0151 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx @@ -6,7 +6,7 @@ import { createContext } from 'react'; import { AppMountContext, PackageInfo } from 'kibana/public'; -import { ApmPluginSetupDeps, ConfigSchema } from '../new-platform/plugin'; +import { ApmPluginSetupDeps, ConfigSchema } from '../../new-platform/plugin'; export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx new file mode 100644 index 0000000000000..46f51da49692a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx @@ -0,0 +1,40 @@ +/* + * 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 React from 'react'; +import { IUrlParams } from './types'; +import { UrlParamsContext, useUiFilters } from '.'; + +const defaultUrlParams = { + page: 0, + serviceName: 'opbeans-python', + transactionType: 'request', + start: '2018-01-10T09:51:41.050Z', + end: '2018-01-10T10:06:41.050Z' +}; + +interface Props { + params?: IUrlParams; + children: React.ReactNode; + refreshTimeRange?: (time: any) => void; +} + +export const MockUrlParamsContextProvider = ({ + params, + children, + refreshTimeRange = () => undefined +}: Props) => { + const urlParams = { ...defaultUrlParams, ...params }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx index 389737a815ab2..8918d992b4f53 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { render, wait } from '@testing-library/react'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import React from 'react'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx index e3ef1d44c8b03..deb805c542b1e 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx @@ -5,8 +5,9 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; // Wrap the hook with a provider so it can useApmPluginContext const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index e34e2c904defb..b85f88040e513 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; import styled from 'styled-components'; import { metadata } from 'ui/metadata'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../../../../plugins/apm/common/alert_types'; import { CoreSetup, CoreStart, @@ -38,6 +40,12 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { + TriggersAndActionsUIPublicPluginSetup, + AlertsContextProvider +} from '../../../../../plugins/triggers_actions_ui/public'; +import { ErrorRateAlertTrigger } from '../components/shared/ErrorRateAlertTrigger'; +import { TransactionDurationAlertTrigger } from '../components/shared/TransactionDurationAlertTrigger'; import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -71,6 +79,7 @@ export interface ApmPluginSetupDeps { data: DataPublicPluginSetup; home: HomePublicPluginSetup; licensing: LicensingPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ConfigSchema { @@ -134,25 +143,58 @@ export class ApmPlugin plugins }; + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.ErrorRate, + name: i18n.translate('xpack.apm.alertTypes.errorRate', { + defaultMessage: 'Error rate' + }), + iconClass: 'bell', + alertParamsExpression: ErrorRateAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration' + }), + iconClass: 'bell', + alertParamsExpression: TransactionDurationAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + ReactDOM.render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + , document.getElementById(REACT_APP_ROOT_ID) ); diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index 6bcfbc4541b64..36c0e18777bfd 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -11,7 +11,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { render, waitForElement } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; @@ -24,12 +24,7 @@ import { ESSearchResponse, ESSearchRequest } from '../../../../../plugins/apm/typings/elasticsearch'; -import { - ApmPluginContext, - ApmPluginContextValue -} from '../context/ApmPluginContext'; -import { ConfigSchema } from '../new-platform/plugin'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -186,57 +181,3 @@ export async function inspectSearchParams( } export type SearchParamsMock = PromiseReturnType; - -const mockCore = { - chrome: { - setBreadcrumbs: () => {} - }, - http: { - basePath: { - prepend: (path: string) => `/basepath${path}` - } - }, - notifications: { - toasts: { - addWarning: () => {}, - addDanger: () => {} - } - } -}; - -const mockConfig: ConfigSchema = { - indexPatternTitle: 'apm-*', - serviceMapEnabled: true, - ui: { - enabled: false - } -}; - -export const mockApmPluginContextValue = { - config: mockConfig, - core: mockCore, - packageInfo: { version: '0' }, - plugins: {} -}; - -export function MockApmPluginContextWrapper({ - children, - value = {} as ApmPluginContextValue -}: { - children?: ReactNode; - value?: ApmPluginContextValue; -}) { - if (value.core?.http) { - createCallApmApi(value.core?.http); - } - return ( - - {children} - - ); -} diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts new file mode 100644 index 0000000000000..51e1f88512965 --- /dev/null +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -0,0 +1,67 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export enum AlertType { + ErrorRate = 'apm.error_rate', + TransactionDuration = 'apm.transaction_duration' +} + +export const ALERT_TYPES_CONFIG = { + [AlertType.ErrorRate]: { + name: i18n.translate('xpack.apm.errorRateAlert.name', { + defaultMessage: 'Error rate threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate('xpack.apm.errorRateAlert.thresholdMet', { + defaultMessage: 'Threshold met' + }) + } + ], + defaultActionGroupId: 'threshold_met' + }, + [AlertType.TransactionDuration]: { + name: i18n.translate('xpack.apm.transactionDurationAlert.name', { + defaultMessage: 'Transaction duration threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate( + 'xpack.apm.transactionDurationAlert.thresholdMet', + { + defaultMessage: 'Threshold met' + } + ) + } + ], + defaultActionGroupId: 'threshold_met' + } +}; + +export const TRANSACTION_ALERT_AGGREGATION_TYPES = { + avg: i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.avg', + { + defaultMessage: 'Average' + } + ), + '95th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.95th', + { + defaultMessage: '95th percentile' + } + ), + '99th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.99th', + { + defaultMessage: '99th percentile' + } + ) +}; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..931fd92e1ecc3 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -6,5 +6,5 @@ "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager","actions", "alerting"] } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts new file mode 100644 index 0000000000000..cb3dd761040da --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -0,0 +1,29 @@ +/* + * 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 { AlertingPlugin } from '../../../../alerting/server'; +import { ActionsPlugin } from '../../../../actions/server'; +import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; +import { registerErrorRateAlertType } from './register_error_rate_alert_type'; +import { APMConfig } from '../..'; + +interface Params { + alerting: AlertingPlugin['setup']; + actions: ActionsPlugin['setup']; + config$: Observable; +} + +export function registerApmAlerts(params: Params) { + registerTransactionDurationAlertType({ + alerting: params.alerting, + config$: params.config$ + }); + registerErrorRateAlertType({ + alerting: params.alerting, + config$: params.config$ + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts new file mode 100644 index 0000000000000..187a75d0b61f2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts @@ -0,0 +1,108 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + ESSearchResponse, + ESSearchRequest +} from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number() +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorRate]; + +export function registerErrorRateAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.ErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.errorIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'error' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + } + ] + } + }, + track_total_hits: true + } + }; + + const response: ESSearchResponse< + unknown, + ESSearchRequest + > = await services.callCluster('search', searchParams); + + const value = response.hits.total.value; + + if (value && value > alertParams.threshold) { + const alertInstance = services.alertInstanceFactory( + AlertType.ErrorRate + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts new file mode 100644 index 0000000000000..7575a8268bc26 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -0,0 +1,140 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + TRANSACTION_DURATION +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + transactionType: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number(), + aggregationType: schema.oneOf([ + schema.literal('avg'), + schema.literal('95th'), + schema.literal('99th') + ]) +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDuration]; + +export function registerTransactionDurationAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.TransactionDuration, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'transaction' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + }, + { + term: { + [TRANSACTION_TYPE]: alertParams.transactionType + } + } + ] + } + }, + aggs: { + agg: + alertParams.aggregationType === 'avg' + ? { + avg: { + field: TRANSACTION_DURATION + } + } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [ + alertParams.aggregationType === '95th' ? 95 : 99 + ] + } + } + } + } + }; + + const response: ESSearchResponse< + unknown, + typeof searchParams + > = await services.callCluster('search', searchParams); + + if (!response.aggregations) { + return; + } + + const { agg } = response.aggregations; + + const value = 'values' in agg ? agg.values[0] : agg.value; + + if (value && value > alertParams.threshold * 1000) { + const alertInstance = services.alertInstanceFactory( + AlertType.TransactionDuration + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..e140340786e8a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,7 +8,10 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { ActionsPlugin } from '../../actions/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; @@ -21,6 +24,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; export interface LegacySetup { server: Server; @@ -47,6 +51,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; + alerting?: AlertingPlugin['setup']; + actions?: ActionsPlugin['setup']; } ) { const logger = this.initContext.logger.get('apm'); @@ -55,6 +62,14 @@ export class APMPlugin implements Plugin { map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) ); + if (plugins.actions && plugins.alerting) { + registerApmAlerts({ + alerting: plugins.alerting, + actions: plugins.actions, + config$: mergedConfig$ + }); + } + this.legacySetup$.subscribe(__LEGACY => { createApmApi().init(core, { config$: mergedConfig$, logger, __LEGACY }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts index e5693e31c2d66..f8102189c425c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts @@ -10,44 +10,21 @@ * will possibly go away with https://github.com/elastic/kibana/issues/52300. */ -export function hasShowAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:show']) { - return true; - } - return false; -} +type Capabilities = Record; -export function hasShowActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:show']) { - return true; - } - return false; -} +const apps = ['apm', 'siem']; -export function hasSaveAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:save']) { - return true; - } - return false; +function hasCapability(capabilities: Capabilities, capability: string) { + return apps.some(app => capabilities[app]?.[capability]); } -export function hasSaveActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:save']) { - return true; - } - return false; +function createCapabilityCheck(capability: string) { + return (capabilities: Capabilities) => hasCapability(capabilities, capability); } -export function hasDeleteAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:delete']) { - return true; - } - return false; -} - -export function hasDeleteActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:delete']) { - return true; - } - return false; -} +export const hasShowAlertsCapability = createCapabilityCheck('alerting:show'); +export const hasShowActionsCapability = createCapabilityCheck('actions:show'); +export const hasSaveAlertsCapability = createCapabilityCheck('alerting:save'); +export const hasSaveActionsCapability = createCapabilityCheck('actions:save'); +export const hasDeleteAlertsCapability = createCapabilityCheck('alerting:delete'); +export const hasDeleteActionsCapability = createCapabilityCheck('actions:delete'); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 342401c4778d8..96645e856e418 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -23,3 +23,7 @@ export function plugin(ctx: PluginInitializerContext) { export { Plugin }; export * from './plugin'; + +export { TIME_UNITS } from './application/constants'; +export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; +export { ForLastExpression } from './common/expression_items/for_the_last';