From 5ae469d54c560e28a69469d82beac550f9fd7458 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 12 Dec 2022 09:28:10 -0500 Subject: [PATCH] [APM] Alert counts in Service groups (#144484) Closes #143613 Closes https://github.com/elastic/kibana/issues/144420 ![Screen Shot 2022-11-03 at 7 32 39 PM](https://user-images.githubusercontent.com/1967266/199854883-9b4b5028-c2c6-46ca-93ce-cce37e31a213.png) ![apm-service-groups-alert-count](https://user-images.githubusercontent.com/1967266/199854840-70c17d59-5594-46c4-8fcb-d3e39e149d27.gif) ![Screen Shot 2022-11-03 at 7 34 41 PM](https://user-images.githubusercontent.com/1967266/199854863-149c638a-e978-41a7-bc3d-ccf1ccc7c53b.png) ![Screen Shot 2022-11-03 at 7 32 21 PM](https://user-images.githubusercontent.com/1967266/199854876-b49249b8-bfa7-4106-a9e8-632c794cffde.png) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Co-authored-by: miriam.aparicio --- .../serverless_metrics/serverless_summary.tsx | 2 +- .../service_group_save/create_button.tsx | 23 +-- .../service_groups_list/index.tsx | 22 +-- .../service_group_card.tsx | 48 ++++-- .../service_groups_list.tsx | 8 +- .../resources/tips_and_resources.tsx | 2 +- .../public/components/shared/links/kibana.ts | 12 ++ x-pack/plugins/apm/server/plugin.ts | 12 ++ .../service_groups/get_apm_alerts_client.ts | 42 +++++ .../get_service_group_alerts.ts | 92 ++++++++++ .../service_groups/get_services_counts.ts | 6 +- .../apm/server/routes/service_groups/route.ts | 96 ++++++----- .../service_groups/save_service_group.ts | 36 ++-- .../rule_registry/common/field_map/types.ts | 1 + .../service_groups/save_service_group.spec.ts | 52 +++--- .../service_group_count/generate_data.ts | 74 ++++++++ .../service_group_count.spec.ts | 159 ++++++++++++++++++ .../service_groups/wait_for_active_alert.ts | 75 +++++++++ 18 files changed, 630 insertions(+), 132 deletions(-) create mode 100644 x-pack/plugins/apm/server/routes/service_groups/get_apm_alerts_client.ts create mode 100644 x-pack/plugins/apm/server/routes/service_groups/get_service_group_alerts.ts create mode 100644 x-pack/test/apm_api_integration/tests/service_groups/service_group_count/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/service_groups/wait_for_active_alert.ts diff --git a/x-pack/plugins/apm/public/components/app/metrics/serverless_metrics/serverless_summary.tsx b/x-pack/plugins/apm/public/components/app/metrics/serverless_metrics/serverless_summary.tsx index 365937c8c48a2..78884b92a27ae 100644 --- a/x-pack/plugins/apm/public/components/app/metrics/serverless_metrics/serverless_summary.tsx +++ b/x-pack/plugins/apm/public/components/app/metrics/serverless_metrics/serverless_summary.tsx @@ -100,7 +100,7 @@ export function ServerlessSummary({ serverlessId }: Props) { {i18n.translate('xpack.apm.serverlessMetrics.summary.feedback', { - defaultMessage: 'Send feedback', + defaultMessage: 'Give feedback', })} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx index 6c872423d577a..1f3eb1ce09750 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx @@ -7,42 +7,21 @@ import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -// import { ServiceGroupsTour } from '../service_groups_tour'; -// import { useServiceGroupsTour } from '../use_service_groups_tour'; interface Props { onClick: () => void; } export function CreateButton({ onClick }: Props) { - // const { tourEnabled, dismissTour } = useServiceGroupsTour('createGroup'); return ( - // { - // dismissTour(); - onClick(); - }} + onClick={onClick} > {i18n.translate('xpack.apm.serviceGroups.createGroupLabel', { defaultMessage: 'Create group', })} - // ); } diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx index 97a51698cd330..4ce7489cf3817 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/index.tsx @@ -16,12 +16,11 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isEmpty, sortBy } from 'lodash'; -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback } from 'react'; import { isPending, useFetcher } from '../../../../hooks/use_fetcher'; import { ServiceGroupsListItems } from './service_groups_list'; import { Sort } from './sort'; import { RefreshServiceGroupsSubscriber } from '../refresh_service_groups_subscriber'; -import { getDateRange } from '../../../../context/url_params_context/helpers'; import { ServiceGroupSaveButton } from '../service_group_save'; import { BetaBadge } from '../../../shared/beta_badge'; @@ -44,20 +43,13 @@ export function ServiceGroupsList() { const { serviceGroups } = data; - const { start, end } = useMemo( - () => getDateRange({ rangeFrom: 'now-24h', rangeTo: 'now' }), - [] - ); - - const { data: servicesCountData = { servicesCounts: {} } } = useFetcher( + const { data: servicesGroupCounts = {} } = useFetcher( (callApmApi) => { - if (start && end && serviceGroups.length) { - return callApmApi('GET /internal/apm/service_groups/services_count', { - params: { query: { start, end } }, - }); + if (serviceGroups.length) { + return callApmApi('GET /internal/apm/service-group/counts'); } }, - [start, end, serviceGroups.length] + [serviceGroups.length] ); const isLoading = isPending(status); @@ -188,7 +180,7 @@ export function ServiceGroupsList() { > {i18n.translate( 'xpack.apm.serviceGroups.beta.feedback.link', - { defaultMessage: 'Send feedback' } + { defaultMessage: 'Give feedback' } )} @@ -199,7 +191,7 @@ export function ServiceGroupsList() { items.length ? ( ) : ( diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx index 080b3772b6250..e4e6b9516474b 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx @@ -6,11 +6,13 @@ */ import { EuiAvatar, + EuiBadge, EuiCard, EuiCardProps, EuiFlexGroup, EuiFlexItem, EuiText, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -18,30 +20,53 @@ import { ServiceGroup, SERVICE_GROUP_COLOR_DEFAULT, } from '../../../../../common/service_groups'; +import { useObservabilityActiveAlertsHref } from '../../../shared/links/kibana'; interface Props { serviceGroup: ServiceGroup; hideServiceCount?: boolean; - onClick?: () => void; href?: string; - servicesCount?: number; + serviceGroupCounts?: { services: number; alerts: number }; } export function ServiceGroupsCard({ serviceGroup, hideServiceCount = false, - onClick, href, - servicesCount, + serviceGroupCounts, }: Props) { + const activeAlertsHref = useObservabilityActiveAlertsHref(serviceGroup.kuery); const cardProps: EuiCardProps = { style: { width: 286 }, icon: ( - + <> + {serviceGroupCounts?.alerts && ( +
+ + {i18n.translate('xpack.apm.serviceGroups.cardsList.alertCount', { + defaultMessage: + '{alertsCount} {alertsCount, plural, one {alert} other {alerts}}', + values: { alertsCount: serviceGroupCounts.alerts }, + })} + + +
+ )} + + ), title: serviceGroup.groupName, description: ( @@ -58,7 +83,7 @@ export function ServiceGroupsCard({ {!hideServiceCount && ( - {servicesCount === undefined ? ( + {serviceGroupCounts === undefined ? ( <>  ) : ( i18n.translate( @@ -66,7 +91,7 @@ export function ServiceGroupsCard({ { defaultMessage: '{servicesCount} {servicesCount, plural, one {service} other {services}}', - values: { servicesCount }, + values: { servicesCount: serviceGroupCounts.services }, } ) )} @@ -75,7 +100,6 @@ export function ServiceGroupsCard({ )} ), - onClick, href, }; diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx index 64935927b9363..51822a644dba5 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx @@ -11,14 +11,15 @@ import { ServiceGroupsCard } from './service_group_card'; import { useApmRouter } from '../../../../hooks/use_apm_router'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useDefaultEnvironment } from '../../../../hooks/use_default_environment'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; interface Props { items: SavedServiceGroup[]; - servicesCounts: Record; + serviceGroupCounts: APIReturnType<'GET /internal/apm/service-group/counts'>; isLoading: boolean; } -export function ServiceGroupsListItems({ items, servicesCounts }: Props) { +export function ServiceGroupsListItems({ items, serviceGroupCounts }: Props) { const router = useApmRouter(); const { query } = useApmParams('/service-groups'); @@ -28,8 +29,9 @@ export function ServiceGroupsListItems({ items, servicesCounts }: Props) { {items.map((item) => ( >; + +export async function getApmAlertsClient({ + plugins, + request, +}: APMRouteHandlerResources) { + const ruleRegistryPluginStart = await plugins.ruleRegistry.start(); + const alertsClient = await ruleRegistryPluginStart.getRacClientWithRequest( + request + ); + const apmAlertsIndices = await alertsClient.getAuthorizedAlertsIndices([ + 'apm', + ]); + + if (!apmAlertsIndices || isEmpty(apmAlertsIndices)) { + throw Error('No alert indices exist for "apm"'); + } + + type ApmAlertsClientSearchParams = Omit< + Parameters[0], + 'index' + >; + + return { + search(searchParams: ApmAlertsClientSearchParams) { + return alertsClient.find({ + ...searchParams, + index: apmAlertsIndices.join(','), + }); + }, + }; +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/get_service_group_alerts.ts b/x-pack/plugins/apm/server/routes/service_groups/get_service_group_alerts.ts new file mode 100644 index 0000000000000..de097dd646ca9 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/service_groups/get_service_group_alerts.ts @@ -0,0 +1,92 @@ +/* + * 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 { kqlQuery } from '@kbn/observability-plugin/server'; +import { ALERT_RULE_PRODUCER, ALERT_STATUS } from '@kbn/rule-data-utils'; +import { + AggregationsCardinalityAggregate, + AggregationsFilterAggregate, + QueryDslQueryContainer, +} from '@elastic/elasticsearch/lib/api/types'; +import { Logger } from '@kbn/core/server'; +import { ApmPluginRequestHandlerContext } from '../typings'; +import { SavedServiceGroup } from '../../../common/service_groups'; +import { ApmAlertsClient } from './get_apm_alerts_client'; + +export async function getServiceGroupAlerts({ + serviceGroups, + apmAlertsClient, + context, + logger, + spaceId, +}: { + serviceGroups: SavedServiceGroup[]; + apmAlertsClient: ApmAlertsClient; + context: ApmPluginRequestHandlerContext; + logger: Logger; + spaceId?: string; +}) { + if (!spaceId || serviceGroups.length === 0) { + return {}; + } + const serviceGroupsKueryMap: Record = + serviceGroups.reduce((acc, sg) => { + return { + ...acc, + [sg.id]: kqlQuery(sg.kuery)[0], + }; + }, {}); + const params = { + size: 0, + query: { + bool: { + filter: [ + { term: { [ALERT_RULE_PRODUCER]: 'apm' } }, + { term: { [ALERT_STATUS]: 'active' } }, + ], + }, + }, + aggs: { + service_groups: { + filters: { + filters: serviceGroupsKueryMap, + }, + aggs: { + alerts_count: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + }, + }, + }; + const result = await apmAlertsClient.search(params); + + interface ServiceGroupsAggResponse { + buckets: Record< + string, + AggregationsFilterAggregate & { + alerts_count: AggregationsCardinalityAggregate; + } + >; + } + + const { buckets: filterAggBuckets } = (result.aggregations + ?.service_groups ?? { buckets: {} }) as ServiceGroupsAggResponse; + + const serviceGroupAlertsCount: Record = Object.keys( + filterAggBuckets + ).reduce((acc, serviceGroupId) => { + return { + ...acc, + [serviceGroupId]: filterAggBuckets[serviceGroupId].alerts_count.value, + }; + }, {}); + + return serviceGroupAlertsCount; +} diff --git a/x-pack/plugins/apm/server/routes/service_groups/get_services_counts.ts b/x-pack/plugins/apm/server/routes/service_groups/get_services_counts.ts index 80582c9fb982f..4dbf9b1026470 100644 --- a/x-pack/plugins/apm/server/routes/service_groups/get_services_counts.ts +++ b/x-pack/plugins/apm/server/routes/service_groups/get_services_counts.ts @@ -23,6 +23,9 @@ export async function getServicesCounts({ end: number; serviceGroups: SavedServiceGroup[]; }) { + if (!serviceGroups.length) { + return {}; + } const serviceGroupsKueryMap: Record = serviceGroups.reduce((acc, sg) => { return { @@ -67,7 +70,8 @@ export async function getServicesCounts({ const buckets: Record = response?.aggregations?.service_groups.buckets ?? {}; - return Object.keys(buckets).reduce((acc, key) => { + + return Object.keys(buckets).reduce>((acc, key) => { return { ...acc, [key]: buckets[key].services_count.value, diff --git a/x-pack/plugins/apm/server/routes/service_groups/route.ts b/x-pack/plugins/apm/server/routes/service_groups/route.ts index dde307efa7c4b..889c88b613387 100644 --- a/x-pack/plugins/apm/server/routes/service_groups/route.ts +++ b/x-pack/plugins/apm/server/routes/service_groups/route.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import datemath from '@kbn/datemath'; import { apmServiceGroupMaxNumberOfServices } from '@kbn/observability-plugin/common'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { kueryRt, rangeRt } from '../default_api_types'; @@ -21,6 +22,8 @@ import { } from '../../../common/service_groups'; import { getServicesCounts } from './get_services_counts'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; +import { getServiceGroupAlerts } from './get_service_group_alerts'; +import { getApmAlertsClient } from './get_apm_alerts_client'; const serviceGroupsRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/service-groups', @@ -41,43 +44,6 @@ const serviceGroupsRoute = createApmServerRoute({ }, }); -const serviceGroupsWithServiceCountRoute = createApmServerRoute({ - endpoint: 'GET /internal/apm/service_groups/services_count', - params: t.type({ - query: rangeRt, - }), - options: { - tags: ['access:apm'], - }, - handler: async ( - resources - ): Promise<{ servicesCounts: Record }> => { - const { context, params } = resources; - const { - savedObjects: { client: savedObjectsClient }, - } = await context.core; - - const { - query: { start, end }, - } = params; - - const apmEventClient = await getApmEventClient(resources); - - const serviceGroups = await getServiceGroups({ - savedObjectsClient, - }); - - return { - servicesCounts: await getServicesCounts({ - apmEventClient, - serviceGroups, - start, - end, - }), - }; - }, -}); - const serviceGroupRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/service-group', params: t.type({ @@ -118,7 +84,7 @@ const serviceGroupSaveRoute = createApmServerRoute({ }), }), options: { tags: ['access:apm', 'access:apm_write'] }, - handler: async (resources): Promise => { + handler: async (resources): ReturnType => { const { context, params } = resources; const { serviceGroupId } = params.query; const { @@ -131,7 +97,7 @@ const serviceGroupSaveRoute = createApmServerRoute({ throw Boom.badRequest(message); } - await saveServiceGroup({ + return saveServiceGroup({ savedObjectsClient, serviceGroupId, serviceGroup: params.body, @@ -188,6 +154,56 @@ const serviceGroupServicesRoute = createApmServerRoute({ return { items }; }, }); +type ServiceGroupCounts = Record; +const serviceGroupCountsRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/service-group/counts', + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const { context, logger, plugins, request } = resources; + const { + savedObjects: { client: savedObjectsClient }, + } = await context.core; + + const spacesPluginStart = await plugins.spaces?.start(); + + const [serviceGroups, apmAlertsClient, apmEventClient, activeSpace] = + await Promise.all([ + getServiceGroups({ savedObjectsClient }), + getApmAlertsClient(resources), + getApmEventClient(resources), + await spacesPluginStart?.spacesService.getActiveSpace(request), + ]); + + const [servicesCounts, serviceGroupAlertsCount] = await Promise.all([ + getServicesCounts({ + apmEventClient, + serviceGroups, + start: datemath.parse('now-24h')!.toDate().getTime(), + end: datemath.parse('now')!.toDate().getTime(), + }), + getServiceGroupAlerts({ + serviceGroups, + apmAlertsClient, + context, + logger, + spaceId: activeSpace?.id, + }), + ]); + const serviceGroupCounts: ServiceGroupCounts = serviceGroups.reduce( + (acc, { id }): ServiceGroupCounts => ({ + ...acc, + [id]: { + services: servicesCounts[id], + alerts: serviceGroupAlertsCount[id], + }, + }), + {} + ); + return serviceGroupCounts; + }, +}); export const serviceGroupRouteRepository = { ...serviceGroupsRoute, @@ -195,5 +211,5 @@ export const serviceGroupRouteRepository = { ...serviceGroupSaveRoute, ...serviceGroupDeleteRoute, ...serviceGroupServicesRoute, - ...serviceGroupsWithServiceCountRoute, + ...serviceGroupCountsRoute, }; diff --git a/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts b/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts index 6f21fa0ef38c7..00891a87898f6 100644 --- a/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts +++ b/x-pack/plugins/apm/server/routes/service_groups/save_service_group.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract } from '@kbn/core/server'; import { APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + SavedServiceGroup, ServiceGroup, } from '../../../common/service_groups'; @@ -20,19 +21,24 @@ export async function saveServiceGroup({ savedObjectsClient, serviceGroupId, serviceGroup, -}: Options) { - // update existing service group - if (serviceGroupId) { - return await savedObjectsClient.update( - APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, - serviceGroupId, - serviceGroup - ); - } - - // create new saved object - return await savedObjectsClient.create( - APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, - serviceGroup - ); +}: Options): Promise { + const { + id, + attributes, + updated_at: updatedAt, + } = await (serviceGroupId + ? savedObjectsClient.update( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroupId, + serviceGroup + ) + : savedObjectsClient.create( + APM_SERVICE_GROUP_SAVED_OBJECT_TYPE, + serviceGroup + )); + return { + id, + ...(attributes as ServiceGroup), + updatedAt: updatedAt ? Date.parse(updatedAt) : 0, + }; } diff --git a/x-pack/plugins/rule_registry/common/field_map/types.ts b/x-pack/plugins/rule_registry/common/field_map/types.ts index 6eeffa12400fe..52ee246375ad0 100644 --- a/x-pack/plugins/rule_registry/common/field_map/types.ts +++ b/x-pack/plugins/rule_registry/common/field_map/types.ts @@ -12,5 +12,6 @@ export interface FieldMap { array?: boolean; path?: string; scaling_factor?: number; + dynamic?: 'strict' | boolean; }; } diff --git a/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts b/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts index 533d6079c1a6d..8eeb4b43f7d30 100644 --- a/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts +++ b/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts @@ -12,9 +12,8 @@ import { expectToReject } from '../../common/utils/expect_to_reject'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); const apmApiClient = getService('apmApiClient'); - const supertest = getService('supertest'); - async function callApi({ + async function createServiceGroupApi({ serviceGroupId, groupName, kuery, @@ -44,38 +43,47 @@ export default function ApiTest({ getService }: FtrProviderContext) { return response; } - type SavedObjectsFindResults = Array<{ - id: string; - type: string; - }>; + async function getServiceGroupsApi() { + return apmApiClient.writeUser({ + endpoint: 'GET /internal/apm/service-groups', + }); + } - async function deleteServiceGroups() { - const response = await supertest - .get('/api/saved_objects/_find?type=apm-service-group') - .set('kbn-xsrf', 'true'); - const savedObjects: SavedObjectsFindResults = response.body.saved_objects; - const bulkDeleteBody = savedObjects.map(({ id, type }) => ({ id, type })); - return supertest - .post(`/api/saved_objects/_bulk_delete?force=true`) - .set('kbn-xsrf', 'foo') - .send(bulkDeleteBody); + async function deleteAllServiceGroups() { + return await getServiceGroupsApi().then((response) => { + const promises = response.body.serviceGroups.map((item) => { + if (item.id) { + return apmApiClient.writeUser({ + endpoint: 'DELETE /internal/apm/service-group', + params: { query: { serviceGroupId: item.id } }, + }); + } + }); + return Promise.all(promises); + }); } registry.when('Service group create', { config: 'basic', archives: [] }, () => { - afterEach(deleteServiceGroups); + afterEach(deleteAllServiceGroups); it('creates a new service group', async () => { - const response = await callApi({ + const serviceGroup = { groupName: 'synthbeans', kuery: 'service.name: synth*', - }); - expect(response.status).to.be(200); - expect(Object.keys(response.body).length).to.be(0); + }; + const createResponse = await createServiceGroupApi(serviceGroup); + expect(createResponse.status).to.be(200); + expect(createResponse.body).to.have.property('id'); + expect(createResponse.body).to.have.property('groupName', serviceGroup.groupName); + expect(createResponse.body).to.have.property('kuery', serviceGroup.kuery); + expect(createResponse.body).to.have.property('updatedAt'); + const serviceGroupsResponse = await getServiceGroupsApi(); + expect(serviceGroupsResponse.body.serviceGroups.length).to.be(1); }); it('handles invalid fields with error response', async () => { const err = await expectToReject(() => - callApi({ + createServiceGroupApi({ groupName: 'synthbeans', kuery: 'service.name: synth* or transaction.type: request', }) diff --git a/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/generate_data.ts b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/generate_data.ts new file mode 100644 index 0000000000000..59c1125d559a2 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/generate_data.ts @@ -0,0 +1,74 @@ +/* + * 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 { apm, timerange } from '@kbn/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const synthServices = [ + apm + .service({ name: 'synth-go', environment: 'testing', agentName: 'go' }) + .instance('instance-1'), + apm + .service({ name: 'synth-java', environment: 'testing', agentName: 'java' }) + .instance('instance-2'), + apm + .service({ name: 'opbeans-node', environment: 'testing', agentName: 'nodejs' }) + .instance('instance-3'), + ]; + + await synthtraceEsClient.index( + synthServices.map((service) => + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + service + .transaction({ + transactionName: 'GET /api/product/list', + transactionType: 'request', + }) + .duration(2000) + .timestamp(timestamp) + .children( + service + .span({ + spanName: '/_search', + spanType: 'db', + spanSubtype: 'elasticsearch', + }) + .destination('elasticsearch') + .duration(100) + .success() + .timestamp(timestamp), + service + .span({ + spanName: '/_search', + spanType: 'db', + spanSubtype: 'elasticsearch', + }) + .destination('elasticsearch') + .duration(300) + .success() + .timestamp(timestamp) + ) + .errors( + service.error({ message: 'error 1', type: 'foo' }).timestamp(timestamp), + service.error({ message: 'error 2', type: 'foo' }).timestamp(timestamp), + service.error({ message: 'error 3', type: 'bar' }).timestamp(timestamp) + ) + ) + ) + ); +} diff --git a/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts new file mode 100644 index 0000000000000..a1ee3490323fb --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { waitForActiveAlert } from '../wait_for_active_alert'; +import { generateData } from './generate_data'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const supertest = getService('supertest'); + const synthtraceEsClient = getService('synthtraceEsClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const esClient = getService('es'); + const log = getService('log'); + const start = Date.now() - 24 * 60 * 60 * 1000; + const end = Date.now(); + + async function getServiceGroupCounts() { + return apmApiClient.readUser({ + endpoint: 'GET /internal/apm/service-group/counts', + }); + } + + async function saveServiceGroup({ + serviceGroupId, + groupName, + kuery, + description, + color, + }: { + serviceGroupId?: string; + groupName: string; + kuery: string; + description?: string; + color?: string; + }) { + return apmApiClient.writeUser({ + endpoint: 'POST /internal/apm/service-group', + params: { + query: { + serviceGroupId, + }, + body: { + groupName, + kuery, + description, + color, + }, + }, + }); + } + + async function getServiceGroupsApi() { + return apmApiClient.writeUser({ + endpoint: 'GET /internal/apm/service-groups', + }); + } + + async function deleteAllServiceGroups() { + return await getServiceGroupsApi().then((response) => { + const promises = response.body.serviceGroups.map((item) => { + if (item.id) { + return apmApiClient.writeUser({ + endpoint: 'DELETE /internal/apm/service-group', + params: { query: { serviceGroupId: item.id } }, + }); + } + }); + return Promise.all(promises); + }); + } + + async function createRule() { + return supertest + .post(`/api/alerting/rule`) + .set('kbn-xsrf', 'true') + .send({ + params: { + serviceName: 'synth-go', + transactionType: '', + windowSize: 99, + windowUnit: 'y', + threshold: 100, + aggregationType: 'avg', + environment: 'testing', + }, + consumer: 'apm', + schedule: { interval: '1m' }, + tags: ['apm'], + name: 'Latency threshold | synth-go', + rule_type_id: ApmRuleType.TransactionDuration, + notify_when: 'onActiveAlert', + actions: [], + }); + } + + registry.when('Service group counts', { config: 'basic', archives: [] }, () => { + let synthbeansServiceGroupId: string; + let opbeansServiceGroupId: string; + before(async () => { + const [, { body: synthbeansServiceGroup }, { body: opbeansServiceGroup }] = await Promise.all( + [ + generateData({ start, end, synthtraceEsClient }), + saveServiceGroup({ + groupName: 'synthbeans', + kuery: 'service.name: synth*', + }), + saveServiceGroup({ + groupName: 'opbeans', + kuery: 'service.name: opbeans*', + }), + ] + ); + synthbeansServiceGroupId = synthbeansServiceGroup.id; + opbeansServiceGroupId = opbeansServiceGroup.id; + }); + + after(async () => { + await deleteAllServiceGroups(); + await synthtraceEsClient.clean(); + }); + + it('returns the correct number of services', async () => { + const response = await getServiceGroupCounts(); + expect(response.status).to.be(200); + expect(Object.keys(response.body).length).to.be(2); + expect(response.body[synthbeansServiceGroupId]).to.have.property('services', 2); + expect(response.body[opbeansServiceGroupId]).to.have.property('services', 1); + }); + + describe('with alerts', () => { + let ruleId: string; + before(async () => { + const { body: createdRule } = await createRule(); + ruleId = createdRule.id; + await waitForActiveAlert({ ruleId, esClient, log }); + }); + + after(async () => { + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'true'); + await esDeleteAllIndices('.alerts*'); + }); + + it('returns the correct number of alerts', async () => { + const response = await getServiceGroupCounts(); + expect(response.status).to.be(200); + expect(Object.keys(response.body).length).to.be(2); + expect(response.body[synthbeansServiceGroupId]).to.have.property('alerts', 1); + expect(response.body[opbeansServiceGroupId]).to.have.property('alerts', 0); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/service_groups/wait_for_active_alert.ts b/x-pack/test/apm_api_integration/tests/service_groups/wait_for_active_alert.ts new file mode 100644 index 0000000000000..cdac9ddcf203d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_groups/wait_for_active_alert.ts @@ -0,0 +1,75 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import expect from '@kbn/expect'; +import { Client } from '@elastic/elasticsearch'; + +const WAIT_FOR_STATUS_INCREMENT = 1000; + +export async function waitForActiveAlert({ + ruleId, + waitMillis = 10000, + esClient, + log, +}: { + ruleId: string; + waitMillis?: number; + esClient: Client; + log: ToolingLog; +}): Promise> { + if (waitMillis < 0) { + expect().fail(`waiting for active alert for rule ${ruleId} timed out`); + } + + const searchParams = { + index: '.alerts-observability.apm.alerts-*', + size: 1, + query: { + bool: { + filter: [ + { + term: { + 'kibana.alert.rule.producer': 'apm', + }, + }, + { + term: { + 'kibana.alert.status': 'active', + }, + }, + { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + ], + }, + }, + }; + const response = await esClient.search(searchParams); + + const hits = (response?.hits?.total as { value: number } | undefined)?.value ?? 0; + if (hits > 0) { + return response.hits.hits[0]; + } + + const message = `waitForActiveAlert(${ruleId}): got ${hits} hits.`; + + log.debug(`${message}, retrying`); + + await delay(WAIT_FOR_STATUS_INCREMENT); + return await waitForActiveAlert({ + ruleId, + waitMillis: waitMillis - WAIT_FOR_STATUS_INCREMENT, + esClient, + log, + }); +} + +async function delay(millis: number): Promise { + await new Promise((resolve) => setTimeout(resolve, millis)); +}