diff --git a/x-pack/plugins/apm/common/rules/schema.ts b/x-pack/plugins/apm/common/rules/schema.ts index 58a5b40da41f2..5a7e97028088d 100644 --- a/x-pack/plugins/apm/common/rules/schema.ts +++ b/x-pack/plugins/apm/common/rules/schema.ts @@ -29,6 +29,9 @@ export const transactionDurationParamsSchema = schema.object({ schema.literal(AggregationType.P99), ]), environment: schema.string(), + groupBy: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }); export const anomalyParamsSchema = schema.object({ diff --git a/x-pack/plugins/apm/common/utils/get_groupby_terms.test.ts b/x-pack/plugins/apm/common/utils/get_groupby_terms.test.ts new file mode 100644 index 0000000000000..decebdfcab211 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/get_groupby_terms.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGroupByTerms } from './get_groupby_terms'; + +describe('get terms fields based on group-by', () => { + it('returns single terms field', () => { + const ruleParams = { groupBy: 'service.name' }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([ + { field: 'service.name', missing: 'SERVICE_NAME_NOT_DEFINED' }, + ]); + }); + + it('returns multiple terms fields', () => { + const ruleParams = { + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([ + { field: 'service.name', missing: 'SERVICE_NAME_NOT_DEFINED' }, + { + field: 'service.environment', + missing: 'SERVICE_ENVIRONMENT_NOT_DEFINED', + }, + { field: 'transaction.type', missing: 'TRANSACTION_TYPE_NOT_DEFINED' }, + { field: 'transaction.name', missing: 'TRANSACTION_NAME_NOT_DEFINED' }, + ]); + }); + + it('returns an empty array', () => { + const ruleParams = { groupBy: undefined }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([]); + }); +}); diff --git a/x-pack/plugins/apm/common/utils/get_groupby_terms.ts b/x-pack/plugins/apm/common/utils/get_groupby_terms.ts new file mode 100644 index 0000000000000..bac14a383eb17 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/get_groupby_terms.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getGroupByTerms = (groupBy: string[] | string | undefined) => { + return (groupBy ? [groupBy] : []).flat().map((group) => { + return { + field: group, + missing: group.replaceAll('.', '_').toUpperCase().concat('_NOT_DEFINED'), + }; + }); +}; diff --git a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_duration_rule_type/index.tsx b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_duration_rule_type/index.tsx index a94cad8767369..20b0d35cde96f 100644 --- a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_duration_rule_type/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_duration_rule_type/index.tsx @@ -8,13 +8,15 @@ import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { defaults, map, omit } from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ForLastExpression, TIME_UNITS, } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { AggregationType } from '../../../../../common/rules/apm_rule_types'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; @@ -34,6 +36,7 @@ import { import { AlertMetadata, getIntervalAndTimeRange } from '../../utils/helper'; import { ApmRuleParamsContainer } from '../../ui_components/apm_rule_params_container'; import { PopoverExpression } from '../../ui_components/popover_expression'; +import { APMRuleGroupBy } from '../../ui_components/apm_rule_group_by'; export interface RuleParams { aggregationType: AggregationType; @@ -43,6 +46,7 @@ export interface RuleParams { transactionType: string; windowSize: number; windowUnit: string; + groupBy?: string | string[] | undefined; } const TRANSACTION_ALERT_AGGREGATION_TYPES: Record = { @@ -142,6 +146,13 @@ export function TransactionDurationRuleType(props: Props) { /> ); + const onGroupByChange = useCallback( + (group: string[] | string | null) => { + setRuleParams('groupBy', group && group.length ? group : ''); + }, + [setRuleParams] + ); + const fields = [ , ]; + const groupAlertsBy = ( + <> + + + + + + ); + return ( diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_group_by.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_group_by.tsx new file mode 100644 index 0000000000000..e9206168b6d82 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_group_by.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBox } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback } from 'react'; +import { + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, +} from '../../../../common/es_fields/apm'; +import { TRANSACTION_TYPE } from './alert_details_app_section/types'; + +interface Props { + options: { groupBy: string[] | string | undefined }; + onChange: (groupBy: string | null | string[]) => void; + errorOptions?: string[]; +} + +const preSelectedFields: string[] = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, +]; + +const fields: string[] = [TRANSACTION_NAME]; + +export function APMRuleGroupBy({ options, onChange, errorOptions }: Props) { + const handleChange = useCallback( + (selectedOptions: Array<{ label: string }>) => { + const groupByOption = selectedOptions.map((option) => option.label); + onChange( + groupByOption.filter((group) => !preSelectedFields.includes(group)) + ); + }, + [onChange] + ); + + const selectedOptions = [ + ...preSelectedFields.map((field) => ({ + label: field, + color: 'lightgray', + })), + ...(Array.isArray(options.groupBy) + ? options.groupBy.map((field) => ({ + label: field, + color: errorOptions?.includes(field) ? 'danger' : undefined, + })) + : options.groupBy + ? [ + { + label: options.groupBy, + color: errorOptions?.includes(options.groupBy) + ? 'danger' + : undefined, + }, + ] + : []), + ]; + + return ( + ({ label: field }))} + onChange={handleChange} + isClearable={true} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx index 57d27c6cb6e31..b651fb29a824f 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx @@ -24,6 +24,7 @@ interface Props { setRuleProperty: (key: string, value: any) => void; defaultParams: Record; fields: React.ReactNode[]; + groupAlertsBy?: React.ReactNode; chartPreview?: React.ReactNode; minimumWindowSize?: MinimumWindowSize; } @@ -31,6 +32,7 @@ interface Props { export function ApmRuleParamsContainer(props: Props) { const { fields, + groupAlertsBy, setRuleParams, defaultParams, chartPreview, @@ -72,6 +74,7 @@ export function ApmRuleParamsContainer(props: Props) { {chartPreview} + {groupAlertsBy} ); } diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts index dcd8994860ae7..5282b42b35546 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts @@ -71,4 +71,208 @@ describe('registerTransactionDurationRuleType', () => { 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', }); }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: [ + 'opbeans-java', + 'ENVIRONMENT_NOT_DEFINED', + 'request', + 'GET /products', + ], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: ['transaction.name'], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'Not defined', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', + 'transaction.name': 'GET /products', + }); + }); + + it('sends alert when rule is configured with undefined group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['opbeans-java', 'ENVIRONMENT_NOT_DEFINED', 'request'], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: undefined, + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'Not defined', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', + }); + }); + + it('sends alert when rule is configured with group by field that does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: [ + 'opbeans-java', + 'ENVIRONMENT_NOT_DEFINED', + 'request', + 'TRANSACTION_NAME_NOT_DEFINED', + ], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: ['transaction.name'], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'Not defined', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', + 'transaction.name': 'TRANSACTION_NAME_NOT_DEFINED', + }); + }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index d7d763bfb2ca6..4b0c309129990 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -22,6 +22,7 @@ import { import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; +import { getGroupByTerms } from '../../../../../common/utils/get_groupby_terms'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; import { ENVIRONMENT_NOT_DEFINED, @@ -166,6 +167,7 @@ export function registerTransactionDurationRuleType({ missing: ENVIRONMENT_NOT_DEFINED.value, }, { field: TRANSACTION_TYPE }, + ...getGroupByTerms(ruleParams.groupBy), ], size: 1000, ...getMultiTermsSortOrder(ruleParams.aggregationType), @@ -196,7 +198,24 @@ export function registerTransactionDurationRuleType({ const triggeredBuckets = []; + const predefinedGroupby: string[] = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + ]; + const allGroupbyFields: string[] = [ + ...predefinedGroupby, + ...[ruleParams.groupBy ?? []].flat(), + ]; + for (const bucket of response.aggregations.series.buckets) { + const bucketKeyValues: Record = {}; + bucket.key.forEach((key, keyIndex) => { + if (!predefinedGroupby.includes(allGroupbyFields[keyIndex])) { + bucketKeyValues[allGroupbyFields[keyIndex]] = key; + } + }); + const [serviceName, environment, transactionType] = bucket.key; const transactionDuration = @@ -214,6 +233,7 @@ export function registerTransactionDurationRuleType({ sourceFields: getServiceGroupFields(bucket), transactionType, transactionDuration, + groupbyFields: bucketKeyValues, }); } } @@ -224,6 +244,7 @@ export function registerTransactionDurationRuleType({ transactionType, transactionDuration, sourceFields, + groupbyFields, } of triggeredBuckets) { const environmentLabel = getEnvironmentLabel(environment); @@ -288,6 +309,7 @@ export function registerTransactionDurationRuleType({ transactionType, triggerValue: transactionDurationFormatted, viewInAppUrl, + ...groupbyFields, }); } diff --git a/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts new file mode 100644 index 0000000000000..bbc830e56cf8d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AggregationType, ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createApmRule, + createIndexConnector, + fetchServiceInventoryAlertCounts, + fetchServiceTabAlertCount, +} from './alerting_api_helper'; +import { waitForRuleStatus, waitForDocumentInIndex } from './wait_for_rule_status'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + + const supertest = getService('supertest'); + const es = getService('es'); + const apmApiClient = getService('apmApiClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('transaction duration alert', { config: 'basic', archives: [] }, () => { + let ruleId: string; + let actionId: string | undefined; + + const INDEX_NAME = 'transaction-duration'; + + before(async () => { + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: 'production', agentName: 'java' }) + .instance('instance'); + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'production', agentName: 'node' }) + .instance('instance'); + const events = timerange('now-15m', 'now') + .ratePerMinute(1) + .generator((timestamp) => { + return [ + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(5000) + .success(), + opbeansNode + .transaction({ transactionName: 'tx-node' }) + .timestamp(timestamp) + .duration(4000) + .success(), + ]; + }); + await synthtraceEsClient.index(events); + }); + + after(async () => { + await synthtraceEsClient.clean(); + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([INDEX_NAME]); + await es.deleteByQuery({ + index: '.alerts*', + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await es.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + }); + }); + + describe('create alert with transaction.name group by', () => { + before(async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Transation duration API test', + indexName: INDEX_NAME, + }); + const createdRule = await createApmRule({ + supertest, + ruleTypeId: ApmRuleType.TransactionDuration, + name: 'Apm transaction duration', + params: { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + environment: 'production', + aggregationType: AggregationType.Avg, + groupBy: 'transaction.name', + }, + actions: [ + { + group: 'threshold_met', + id: actionId, + params: { + documents: [{ message: 'Transaction Name: {{context.transaction.name}}' }], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + expect(createdRule.id).to.not.eql(undefined); + ruleId = createdRule.id; + }); + + it('checks if alert is active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('returns correct message', async () => { + const resp = await waitForDocumentInIndex<{ message: string }>({ + es, + indexName: INDEX_NAME, + }); + + expect(resp.hits.hits[0]._source?.message).eql(`Transaction Name: tx-java`); + }); + + it('shows the correct alert count for each service on service inventory', async () => { + const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); + expect(serviceInventoryAlertCounts).to.eql({ + 'opbeans-node': 0, + 'opbeans-java': 1, + }); + }); + + it('shows the correct alert count in opbeans-java service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-java', + }); + expect(serviceTabAlertCount).to.be(1); + }); + + it('shows the correct alert count in opbeans-node service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-node', + }); + expect(serviceTabAlertCount).to.be(0); + }); + }); + }); +}