['tab'];
@@ -18,6 +19,7 @@ interface Props {
export function ServiceDetails({ tab }: Props) {
const { urlParams } = useUrlParams();
const { serviceName } = urlParams;
+ const callApmApi = useCallApmApi();
return (
@@ -29,7 +31,10 @@ export function ServiceDetails({ tab }: Props) {
-
+
diff --git a/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/create_alert.ts b/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/create_alert.ts
new file mode 100644
index 0000000000000..6e6ebd79cbb4b
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/create_alert.ts
@@ -0,0 +1,159 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { ERROR_OCCURRENCE_ALERT_TYPE_ID } from '../../../../common/alerting/constants';
+import { ActionsClient } from '../../../../../actions';
+import { AlertsClient } from '../../../../../alerting';
+
+interface Properties {
+ serviceName: string;
+ threshold: number;
+ interval: string;
+ actions: {
+ email: string;
+ slack: string;
+ };
+}
+
+interface Clients {
+ alertsClient: AlertsClient;
+ actionsClient: ActionsClient;
+}
+
+interface Action {
+ actionTypeId: string;
+ description: string;
+ config: Record;
+ secrets: Record;
+}
+const createAlert = async (
+ { alertsClient, actionsClient }: Clients,
+ { actions: { slack, email }, serviceName, threshold, interval }: Properties
+) => {
+ const actions: Array<{ params: Record; action: Action }> = [];
+
+ const values = {
+ errorLogMessage: '{{{context.errorLogMessage}}}',
+ errorCulprit: '{{{context.errorCulprit}}}',
+ docCount: '{{context.docCount}}',
+ serviceName,
+ threshold,
+ interval
+ };
+
+ if (email) {
+ actions.push({
+ action: {
+ actionTypeId: '.email',
+ description: 'Email notifications for error occurrences',
+ config: {
+ from: ''
+ },
+ secrets: {
+ user: '',
+ password: ''
+ }
+ },
+ params: {
+ to: email,
+ subject: i18n.translate(
+ 'xpack.apm.serviceDetails.enableErrorReportsPanel.emailSubjectText',
+ {
+ defaultMessage:
+ '{serviceName} has error groups which exceeds the threshold',
+ values: { serviceName }
+ }
+ ),
+ body: i18n.translate(
+ 'xpack.apm.serviceDetails.enableErrorReportsPanel.emailTemplateText',
+ {
+ defaultMessage:
+ `Your service {serviceName} has error groups which exceeds {threshold} occurrences within {interval}
` +
+ '
' +
+ '{errorLogMessage}
' +
+ '{errorCulprit}
' +
+ '{docCount} occurrences
',
+ values
+ }
+ )
+ }
+ });
+ }
+
+ if (slack) {
+ actions.push({
+ action: {
+ actionTypeId: '.slack',
+ description: 'Slack notifications for error occurrences',
+ config: {},
+ secrets: {
+ webhookUrl: slack
+ }
+ },
+ params: {
+ message: i18n.translate(
+ 'xpack.apm.serviceDetails.enableErrorReportsPanel.slackTemplateText',
+ {
+ defaultMessage: `Your service *{serviceName}* has error groups which exceeds {threshold} occurrences within {interval}
+>*{errorLogMessage}*
+>\`{errorCulprit}\`
+>{docCount} occurrences`,
+ values
+ }
+ )
+ }
+ });
+ }
+
+ if (!actions.length) {
+ throw new Error(
+ 'No actions were defined. Need at least one of email, slack'
+ );
+ }
+
+ const actionResults = await Promise.all(
+ actions.map(({ action, params }) => {
+ return actionsClient
+ .create({
+ action: {
+ actionTypeId: action.actionTypeId,
+ description: action.description,
+ config: action.config,
+ secrets: action.secrets
+ }
+ })
+ .then(result => ({
+ result,
+ params
+ }));
+ })
+ );
+
+ return alertsClient.create({
+ data: {
+ name: `Error threshold for ${serviceName} exceeded`,
+ alertTypeId: ERROR_OCCURRENCE_ALERT_TYPE_ID,
+ alertTypeParams: {
+ serviceName,
+ threshold,
+ interval
+ },
+ interval,
+ enabled: true,
+ actions: actionResults.map(({ result, params }) => {
+ return {
+ group: 'default',
+ id: result.id,
+ params
+ };
+ }),
+ throttle: null
+ }
+ });
+};
+
+export { createAlert };
diff --git a/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/register_error_occurrence_alert_type.ts b/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/register_error_occurrence_alert_type.ts
new file mode 100644
index 0000000000000..70b72c55dae95
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/alerting/error_occurrence/register_error_occurrence_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 { InternalCoreSetup } from 'src/core/server';
+import { idx } from '@kbn/elastic-idx';
+import { ESSearchResponse } from '../../../../typings/elasticsearch';
+import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
+import { APMError } from '../../../../typings/es_schemas/ui/APMError';
+import { ERROR_OCCURRENCE_ALERT_TYPE_ID } from '../../../../common/alerting/constants';
+import {
+ SERVICE_NAME,
+ PROCESSOR_EVENT,
+ ERROR_LOG_MESSAGE,
+ ERROR_EXC_MESSAGE,
+ ERROR_EXC_HANDLED,
+ ERROR_CULPRIT,
+ ERROR_GROUP_ID
+} from '../../../../common/elasticsearch_fieldnames';
+
+const paramsSchema = schema.object({
+ threshold: schema.number({ min: 1 }),
+ interval: schema.string(),
+ serviceName: schema.string()
+});
+
+const registerErrorOccurrenceAlertType = async (core: InternalCoreSetup) => {
+ const { alerting, elasticsearch } = core.http.server.plugins;
+
+ if (!alerting) {
+ throw new Error(
+ 'Cannot register error occurrence alert type. Both the actions and alerting plugins need to be enabled'
+ );
+ }
+
+ alerting.setup.registerType({
+ id: ERROR_OCCURRENCE_ALERT_TYPE_ID,
+ name: 'Error occurrences',
+ actionGroups: ['default'],
+ validate: {
+ params: paramsSchema
+ },
+ async executor({ params, services }) {
+ const { threshold, interval, serviceName } = params as TypeOf<
+ typeof paramsSchema
+ >;
+
+ const request = {
+ body: {
+ query: {
+ bool: {
+ filter: [
+ { term: { [SERVICE_NAME]: serviceName } },
+ { term: { [PROCESSOR_EVENT]: 'error' } },
+ {
+ range: {
+ '@timestamp': {
+ gte: `now-${interval}`
+ }
+ }
+ }
+ ]
+ }
+ },
+ aggs: {
+ error_groups: {
+ terms: {
+ min_doc_count: threshold,
+ field: ERROR_GROUP_ID,
+ size: 10,
+ order: {
+ _count: 'desc' as const
+ }
+ },
+ aggs: {
+ sample: {
+ top_hits: {
+ _source: [
+ ERROR_LOG_MESSAGE,
+ ERROR_EXC_MESSAGE,
+ ERROR_EXC_HANDLED,
+ ERROR_CULPRIT,
+ ERROR_GROUP_ID,
+ '@timestamp'
+ ],
+ sort: [
+ {
+ '@timestamp': 'desc' as const
+ }
+ ],
+ size: 1
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ const { callWithInternalUser } = elasticsearch.getCluster('admin');
+
+ const response: ESSearchResponse<
+ unknown,
+ typeof request
+ > = await callWithInternalUser('search', request);
+ const { aggregations } = response;
+
+ if (!aggregations) {
+ throw new Error(
+ 'No aggregations were returned for search. This happens when no matching indices are found.'
+ );
+ }
+
+ const infringingErrorGroups = aggregations.error_groups.buckets;
+
+ const shouldAlert = infringingErrorGroups.length > 0;
+
+ if (shouldAlert) {
+ const alertInstance = services.alertInstanceFactory('');
+
+ const sampleError = infringingErrorGroups[0].sample.hits.hits[0]
+ ._source as APMError;
+
+ alertInstance.scheduleActions('default', {
+ errorGroupsBuckets: infringingErrorGroups,
+ errorLogMessage:
+ idx(sampleError, _ => _.error.log.message) ||
+ idx(sampleError, _ => _.error.exception[0].message),
+ errorCulprit:
+ idx(sampleError, _ => _.error.culprit) || NOT_AVAILABLE_LABEL,
+ docCount: infringingErrorGroups[0].doc_count
+ });
+ }
+ }
+ });
+};
+
+export { registerErrorOccurrenceAlertType };
diff --git a/x-pack/legacy/plugins/apm/server/lib/alerting/register_alert_types.ts b/x-pack/legacy/plugins/apm/server/lib/alerting/register_alert_types.ts
new file mode 100644
index 0000000000000..2654ee40bfa8b
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/alerting/register_alert_types.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { InternalCoreSetup } from 'src/core/server';
+import { registerErrorOccurrenceAlertType } from './error_occurrence/register_error_occurrence_alert_type';
+
+const registerAlertTypes = (core: InternalCoreSetup) => {
+ const { server } = core.http;
+
+ const { alerting } = server.plugins;
+
+ if (alerting) {
+ const registerFns = [registerErrorOccurrenceAlertType];
+
+ registerFns.forEach(fn => {
+ fn(core);
+ });
+ }
+};
+
+export { registerAlertTypes };
diff --git a/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts b/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts
index 0458c8e4fedf0..e2caabee91aeb 100644
--- a/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts
+++ b/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts
@@ -9,11 +9,13 @@ import { makeApmUsageCollector } from '../lib/apm_telemetry';
import { CoreSetupWithUsageCollector } from '../lib/apm_telemetry/make_apm_usage_collector';
import { createApmAgentConfigurationIndex } from '../lib/settings/agent_configuration/create_agent_config_index';
import { createApmApi } from '../routes/create_apm_api';
+import { registerAlertTypes } from '../lib/alerting/register_alert_types';
export class Plugin {
public setup(core: InternalCoreSetup) {
createApmApi().init(core);
createApmAgentConfigurationIndex(core);
makeApmUsageCollector(core as CoreSetupWithUsageCollector);
+ registerAlertTypes(core);
}
}
diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
index 7d22d9488718f..820477085f70b 100644
--- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
@@ -51,6 +51,7 @@ import {
serviceNodesLocalFiltersRoute,
uiFiltersEnvironmentsRoute
} from './ui_filters';
+import { errorOccurrenceAlertRoute } from './error_occurrence_alert';
import { createApi } from './create_api';
import { serviceMapRoute } from './services';
@@ -110,7 +111,10 @@ const createApmApi = () => {
.add(transactionsLocalFiltersRoute)
.add(serviceNodesLocalFiltersRoute)
.add(uiFiltersEnvironmentsRoute)
- .add(serviceMapRoute);
+ .add(serviceMapRoute)
+
+ // alerts
+ .add(errorOccurrenceAlertRoute);
return api;
};
diff --git a/x-pack/legacy/plugins/apm/server/routes/error_occurrence_alert.ts b/x-pack/legacy/plugins/apm/server/routes/error_occurrence_alert.ts
new file mode 100644
index 0000000000000..1962254ba8057
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/routes/error_occurrence_alert.ts
@@ -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 * as t from 'io-ts';
+import { createRoute } from './create_route';
+import { createAlert } from '../lib/alerting/error_occurrence/create_alert';
+
+export const errorOccurrenceAlertRoute = createRoute(core => ({
+ path: '/api/apm/alerts/error_occurrence',
+ method: 'POST',
+ params: {
+ body: t.type({
+ serviceName: t.string,
+ threshold: t.number,
+ actions: t.type({
+ email: t.string,
+ slack: t.string
+ }),
+ interval: t.string
+ })
+ },
+ handler: async (req, { body }, h) => {
+ const alertsClient =
+ typeof req.getAlertsClient === 'function' ? req.getAlertsClient() : null;
+
+ const actionsClient =
+ typeof req.getActionsClient === 'function'
+ ? req.getActionsClient()
+ : null;
+
+ if (!alertsClient || !actionsClient) {
+ return h.response().code(404);
+ }
+
+ return createAlert({ alertsClient, actionsClient }, body);
+ }
+}));