[APM] Show alert indicator on service inventory page (#147511)

Closes https://github.com/elastic/kibana/issues/146701.

### Changes
- `getApmAlertsClient` was moved from `service-groups` to `helper`
folder.
- `getServicesAlerts` method was created in order to get the active
alerts from services matching filters (timeframe, env, service-group,
etc).
- `getServicesItems` is now also getting the service alerts and merging
them in the results so a knew property was added to service results:
`alertsCount`.
- Alerts badge was added to service inventory page.

**After the changes**

- From service inventory


https://user-images.githubusercontent.com/1313018/207597602-21412be9-be7b-4b8a-8eb8-37416b6c10bc.mov

- From a service group


https://user-images.githubusercontent.com/1313018/207597893-d4d19a21-b936-4bbd-beaa-9f3ac6c051b8.mov

- After Weekly UI presentation and sync with @boriskirov and
@chrisdistasio, we decided to add a loading indicator to
serviceGroupCards to give users a hint that something is loading in the
background. This is how it looks like:


https://user-images.githubusercontent.com/1313018/208681846-4a47f593-1010-4471-86f2-18b196c40684.mov
This commit is contained in:
Yngrid Coello 2022-12-22 18:42:39 +01:00 committed by GitHub
parent 8337c7d72f
commit 3de5b43fba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 390 additions and 102 deletions

View file

@ -17,6 +17,7 @@ export interface ServiceListItem {
latency?: number | null;
transactionErrorRate?: number | null;
environments?: string[];
alertsCount?: number;
}
export enum ServiceInventoryFieldName {
@ -27,4 +28,5 @@ export enum ServiceInventoryFieldName {
Throughput = 'throughput',
Latency = 'latency',
TransactionErrorRate = 'transactionErrorRate',
AlertsCount = 'alertsCount',
}

View file

@ -43,7 +43,7 @@ export function ServiceGroupsList() {
const { serviceGroups } = data;
const { data: servicesGroupCounts = {} } = useFetcher(
const { data: servicesGroupCounts = {}, status: statsStatus } = useFetcher(
(callApmApi) => {
if (serviceGroups.length) {
return callApmApi('GET /internal/apm/service-group/counts');
@ -53,6 +53,7 @@ export function ServiceGroupsList() {
);
const isLoading = isPending(status);
const isLoadingStats = isPending(statsStatus);
const filteredItems = isEmpty(filter)
? serviceGroups
@ -192,7 +193,7 @@ export function ServiceGroupsList() {
<ServiceGroupsListItems
items={items}
serviceGroupCounts={servicesGroupCounts}
isLoading={isLoading}
isLoading={isLoadingStats}
/>
) : (
<EuiEmptyPrompt

View file

@ -11,7 +11,6 @@ import {
EuiCardProps,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
useIsWithinBreakpoints,
} from '@elastic/eui';
@ -22,19 +21,20 @@ import {
SERVICE_GROUP_COLOR_DEFAULT,
} from '../../../../../common/service_groups';
import { useObservabilityActiveAlertsHref } from '../../../shared/links/kibana';
import { ServiceStat } from './service_stat';
interface Props {
serviceGroup: ServiceGroup;
hideServiceCount?: boolean;
href?: string;
serviceGroupCounts?: { services: number; alerts: number };
isLoading: boolean;
}
export function ServiceGroupsCard({
serviceGroup,
hideServiceCount = false,
href,
serviceGroupCounts,
isLoading,
}: Props) {
const isMobile = useIsWithinBreakpoints(['xs', 's']);
@ -43,27 +43,6 @@ export function ServiceGroupsCard({
style: { width: isMobile ? '100%' : 286 },
icon: (
<>
{serviceGroupCounts?.alerts && (
<div>
<EuiBadge
iconType="alert"
color="danger"
href={activeAlertsHref}
{...({
onClick(e: React.SyntheticEvent) {
e.stopPropagation(); // prevents extra click thru to EuiCard's href destination
},
} as object)} // workaround for type check that prevents href + onclick
>
{i18n.translate('xpack.apm.serviceGroups.cardsList.alertCount', {
defaultMessage:
'{alertsCount} {alertsCount, plural, one {alert} other {alerts}}',
values: { alertsCount: serviceGroupCounts.alerts },
})}
</EuiBadge>
<EuiSpacer size="s" />
</div>
)}
<EuiAvatar
name={serviceGroup.groupName}
color={serviceGroup.color || SERVICE_GROUP_COLOR_DEFAULT}
@ -83,24 +62,46 @@ export function ServiceGroupsCard({
)}
</EuiText>
</EuiFlexItem>
{!hideServiceCount && (
<EuiFlexItem>
<EuiText size="s">
{serviceGroupCounts === undefined ? (
<>&nbsp;</>
) : (
i18n.translate(
'xpack.apm.serviceGroups.cardsList.serviceCount',
<EuiFlexGroup>
<ServiceStat loading={isLoading}>
<EuiFlexItem>
<EuiText size="s" textAlign="left">
{serviceGroupCounts !== undefined &&
i18n.translate(
'xpack.apm.serviceGroups.cardsList.serviceCount',
{
defaultMessage:
'{servicesCount} {servicesCount, plural, one {service} other {services}}',
values: { servicesCount: serviceGroupCounts.services },
}
)}
</EuiText>
</EuiFlexItem>
</ServiceStat>
<ServiceStat loading={isLoading} grow={isLoading}>
{serviceGroupCounts && serviceGroupCounts.alerts > 0 && (
<EuiBadge
iconType="alert"
color="danger"
href={activeAlertsHref}
{...({
onClick(e: React.SyntheticEvent) {
e.stopPropagation(); // prevents extra click thru to EuiCard's href destination
},
} as object)} // workaround for type check that prevents href + onclick
>
{i18n.translate(
'xpack.apm.serviceGroups.cardsList.alertCount',
{
defaultMessage:
'{servicesCount} {servicesCount, plural, one {service} other {services}}',
values: { servicesCount: serviceGroupCounts.services },
'{alertsCount} {alertsCount, plural, one {alert} other {alerts}}',
values: { alertsCount: serviceGroupCounts.alerts },
}
)
)}
</EuiText>
</EuiFlexItem>
)}
)}
</EuiBadge>
)}
</ServiceStat>
</EuiFlexGroup>
</EuiFlexGroup>
),
href,

View file

@ -19,7 +19,11 @@ interface Props {
isLoading: boolean;
}
export function ServiceGroupsListItems({ items, serviceGroupCounts }: Props) {
export function ServiceGroupsListItems({
items,
serviceGroupCounts,
isLoading,
}: Props) {
const router = useApmRouter();
const { query } = useApmParams('/service-groups');
@ -40,6 +44,7 @@ export function ServiceGroupsListItems({ items, serviceGroupCounts }: Props) {
kuery: '',
},
})}
isLoading={isLoading}
/>
))}
</EuiFlexGroup>

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexItem, EuiLoadingContent } from '@elastic/eui';
import React, { PropsWithChildren } from 'react';
interface Props {
loading: boolean;
grow?: boolean;
}
export function ServiceStat({
loading,
grow = true,
children,
}: PropsWithChildren<Props>) {
return (
<EuiFlexItem grow={grow}>
{loading ? (
<EuiLoadingContent lines={1} style={{ marginTop: '4px' }} />
) : (
<>{children}</>
)}
</EuiFlexItem>
);
}

View file

@ -202,6 +202,10 @@ export function ServiceInventory() {
...preloadedServices,
].some((item) => 'healthStatus' in item);
const displayAlerts = [...mainStatisticsItems, ...preloadedServices].some(
(item) => ServiceInventoryFieldName.AlertsCount in item
);
const useOptimizedSorting =
useKibana().services.uiSettings?.get<boolean>(
apmServiceInventoryOptimizedSorting
@ -298,6 +302,7 @@ export function ServiceInventory() {
comparisonFetch.status === FETCH_STATUS.LOADING
}
displayHealthStatus={displayHealthStatus}
displayAlerts={displayAlerts}
initialSortField={initialSortField}
initialSortDirection={initialSortDirection}
sortFn={(itemsToSort, sortField, sortDirection) => {

View file

@ -6,6 +6,7 @@
*/
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
@ -18,6 +19,10 @@ import { TypeOf } from '@kbn/typed-react-router-config';
import React, { useMemo } from 'react';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { ServiceHealthStatus } from '../../../../../common/service_health_status';
import {
ServiceInventoryFieldName,
ServiceListItem,
} from '../../../../../common/service_inventory';
import {
TRANSACTION_PAGE_LOAD,
TRANSACTION_REQUEST,
@ -28,26 +33,23 @@ import {
asTransactionRate,
} from '../../../../../common/utils/formatters';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { Breakpoints, useBreakpoints } from '../../../../hooks/use_breakpoints';
import { useFallbackToTransactionsFetcher } from '../../../../hooks/use_fallback_to_transactions_fetcher';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { unit } from '../../../../utils/style';
import { ApmRoutes } from '../../../routing/apm_route_config';
import { AggregatedTransactionsBadge } from '../../../shared/aggregated_transactions_badge';
import {
ChartType,
getTimeSeriesColor,
} from '../../../shared/charts/helper/get_timeseries_color';
import { EnvironmentBadge } from '../../../shared/environment_badge';
import { ListMetric } from '../../../shared/list_metric';
import { ITableColumn, ManagedTable } from '../../../shared/managed_table';
import { ServiceLink } from '../../../shared/service_link';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import {
ChartType,
getTimeSeriesColor,
} from '../../../shared/charts/helper/get_timeseries_color';
import { HealthBadge } from './health_badge';
import {
ServiceInventoryFieldName,
ServiceListItem,
} from '../../../../../common/service_inventory';
type ServicesDetailedStatisticsAPIResponse =
APIReturnType<'POST /internal/apm/services/detailed_statistics'>;
@ -63,19 +65,51 @@ export function getServiceColumns({
comparisonData,
breakpoints,
showHealthStatusColumn,
showAlertsColumn,
link,
}: {
query: TypeOf<ApmRoutes, '/services'>['query'];
showTransactionTypeColumn: boolean;
showHealthStatusColumn: boolean;
showAlertsColumn: boolean;
comparisonDataLoading: boolean;
breakpoints: Breakpoints;
comparisonData?: ServicesDetailedStatisticsAPIResponse;
link: any;
}): Array<ITableColumn<ServiceListItem>> {
const { isSmall, isLarge, isXl } = breakpoints;
const showWhenSmallOrGreaterThanLarge = isSmall || !isLarge;
const showWhenSmallOrGreaterThanXL = isSmall || !isXl;
return [
...(showAlertsColumn
? [
{
field: ServiceInventoryFieldName.AlertsCount,
name: '',
width: `${unit * 5}px`,
sortable: true,
render: (_, { serviceName, alertsCount }) => {
if (!alertsCount) {
return null;
}
return (
<EuiBadge
iconType="alert"
color="danger"
href={link('/services/{serviceName}/alerts', {
path: { serviceName },
query,
})}
>
{alertsCount}
</EuiBadge>
);
},
} as ITableColumn<ServiceListItem>,
]
: []),
...(showHealthStatusColumn
? [
{
@ -242,6 +276,7 @@ interface Props {
isLoading: boolean;
isFailure?: boolean;
displayHealthStatus: boolean;
displayAlerts: boolean;
initialSortField: ServiceInventoryFieldName;
initialPageSize: number;
initialSortDirection: 'asc' | 'desc';
@ -260,12 +295,14 @@ export function ServiceList({
isLoading,
isFailure,
displayHealthStatus,
displayAlerts,
initialSortField,
initialSortDirection,
initialPageSize,
sortFn,
}: Props) {
const breakpoints = useBreakpoints();
const { link } = useApmRouter();
const showTransactionTypeColumn = items.some(
({ transactionType }) =>
@ -298,6 +335,8 @@ export function ServiceList({
comparisonData,
breakpoints,
showHealthStatusColumn: displayHealthStatus,
showAlertsColumn: displayAlerts,
link,
}),
[
query,
@ -306,6 +345,8 @@ export function ServiceList({
comparisonData,
breakpoints,
displayHealthStatus,
displayAlerts,
link,
]
);

View file

@ -35,6 +35,7 @@ const sorts: Record<ServiceInventoryFieldName, SortValueGetter> = {
[ServiceInventoryFieldName.Throughput]: (item) => item.throughput ?? -1,
[ServiceInventoryFieldName.TransactionErrorRate]: (item) =>
item.transactionErrorRate ?? -1,
[ServiceInventoryFieldName.AlertsCount]: (item) => item.alertsCount ?? -1,
};
function reverseSortDirection(sortDirection: 'asc' | 'desc') {

View file

@ -8,11 +8,12 @@
import { composeStories } from '@storybook/testing-react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { getServiceColumns } from '.';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
import { Breakpoints } from '../../../../hooks/use_breakpoints';
import { getServiceColumns } from '.';
import * as stories from './service_list.stories';
import { apmRouter } from '../../../routing/apm_route_config';
import * as timeSeriesColor from '../../../shared/charts/helper/get_timeseries_color';
import * as stories from './service_list.stories';
const { Example, EmptyState } = composeStories(stories);
@ -79,11 +80,13 @@ describe('ServiceList', () => {
isLarge: true,
isXl: true,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) =>
c.render ? c.render!(service[c.field!], service) : service[c.field!]
);
expect(renderedColumns.length).toEqual(7);
expect(renderedColumns[2]).toMatchInlineSnapshot(`
expect(renderedColumns.length).toEqual(8);
expect(renderedColumns[3]).toMatchInlineSnapshot(`
<EnvironmentBadge
environments={
Array [
@ -92,8 +95,8 @@ describe('ServiceList', () => {
}
/>
`);
expect(renderedColumns[3]).toMatchInlineSnapshot(`"request"`);
expect(renderedColumns[4]).toMatchInlineSnapshot(`
expect(renderedColumns[4]).toMatchInlineSnapshot(`"request"`);
expect(renderedColumns[5]).toMatchInlineSnapshot(`
<ListMetric
color="green"
comparisonSeriesColor="black"
@ -117,11 +120,13 @@ describe('ServiceList', () => {
isLarge: true,
isXl: true,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) =>
c.render ? c.render!(service[c.field!], service) : service[c.field!]
);
expect(renderedColumns.length).toEqual(5);
expect(renderedColumns[2]).toMatchInlineSnapshot(`
expect(renderedColumns.length).toEqual(6);
expect(renderedColumns[3]).toMatchInlineSnapshot(`
<ListMetric
color="green"
comparisonSeriesColor="black"
@ -144,11 +149,13 @@ describe('ServiceList', () => {
isLarge: false,
isXl: true,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) =>
c.render ? c.render!(service[c.field!], service) : service[c.field!]
);
expect(renderedColumns.length).toEqual(6);
expect(renderedColumns[2]).toMatchInlineSnapshot(`
expect(renderedColumns.length).toEqual(7);
expect(renderedColumns[3]).toMatchInlineSnapshot(`
<EnvironmentBadge
environments={
Array [
@ -157,7 +164,7 @@ describe('ServiceList', () => {
}
/>
`);
expect(renderedColumns[3]).toMatchInlineSnapshot(`
expect(renderedColumns[4]).toMatchInlineSnapshot(`
<ListMetric
color="green"
comparisonSeriesColor="black"
@ -181,11 +188,13 @@ describe('ServiceList', () => {
isLarge: false,
isXl: false,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) =>
c.render ? c.render!(service[c.field!], service) : service[c.field!]
);
expect(renderedColumns.length).toEqual(7);
expect(renderedColumns[2]).toMatchInlineSnapshot(`
expect(renderedColumns.length).toEqual(8);
expect(renderedColumns[3]).toMatchInlineSnapshot(`
<EnvironmentBadge
environments={
Array [
@ -194,8 +203,8 @@ describe('ServiceList', () => {
}
/>
`);
expect(renderedColumns[3]).toMatchInlineSnapshot(`"request"`);
expect(renderedColumns[4]).toMatchInlineSnapshot(`
expect(renderedColumns[4]).toMatchInlineSnapshot(`"request"`);
expect(renderedColumns[5]).toMatchInlineSnapshot(`
<ListMetric
color="green"
comparisonSeriesColor="black"
@ -221,6 +230,8 @@ describe('ServiceList', () => {
isLarge: false,
isXl: false,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) => c.field);
expect(renderedColumns.includes('healthStatus')).toBeFalsy();
});
@ -238,8 +249,48 @@ describe('ServiceList', () => {
isLarge: false,
isXl: false,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) => c.field);
expect(renderedColumns.includes('healthStatus')).toBeTruthy();
});
});
describe('without Alerts data', () => {
it('hides alertsCount column', () => {
const renderedColumns = getServiceColumns({
comparisonDataLoading: false,
showHealthStatusColumn: false,
query,
showTransactionTypeColumn: true,
breakpoints: {
isSmall: false,
isLarge: false,
isXl: false,
} as Breakpoints,
showAlertsColumn: false,
link: apmRouter.link,
}).map((c) => c.field);
expect(renderedColumns.includes('alertsCount')).toBeFalsy();
});
});
describe('with Alerts data', () => {
it('shows alertsCount column', () => {
const renderedColumns = getServiceColumns({
comparisonDataLoading: false,
showHealthStatusColumn: true,
query,
showTransactionTypeColumn: true,
breakpoints: {
isSmall: false,
isLarge: false,
isXl: false,
} as Breakpoints,
showAlertsColumn: true,
link: apmRouter.link,
}).map((c) => c.field);
expect(renderedColumns.includes('alertsCount')).toBeTruthy();
});
});
});

View file

@ -122,7 +122,7 @@ export function ServiceGroupTemplate({
<EuiIcon size="s" type="arrowLeft" />{' '}
{i18n.translate(
'xpack.apm.serviceGroups.breadcrumb.return',
{ defaultMessage: 'Return' }
{ defaultMessage: 'Return to service groups' }
)}
</>
),

View file

@ -6,7 +6,7 @@
*/
import { isEmpty } from 'lodash';
import { APMRouteHandlerResources } from '../typings';
import { APMRouteHandlerResources } from '../../routes/typings';
export type ApmAlertsClient = Awaited<ReturnType<typeof getApmAlertsClient>>;

View file

@ -15,7 +15,7 @@ import {
import { Logger } from '@kbn/core/server';
import { ApmPluginRequestHandlerContext } from '../typings';
import { SavedServiceGroup } from '../../../common/service_groups';
import { ApmAlertsClient } from './get_apm_alerts_client';
import { ApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client';
export async function getServiceGroupAlerts({
serviceGroups,

View file

@ -23,7 +23,7 @@ import {
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';
import { getApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client';
const serviceGroupsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-groups',

View file

@ -219,6 +219,7 @@ Array [
"track_total_hits": false,
},
},
undefined,
]
`;

View file

@ -0,0 +1,88 @@
/*
* 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 {
AggregationsCardinalityAggregate,
AggregationsFilterAggregate,
} from '@elastic/elasticsearch/lib/api/types';
import { kqlQuery } from '@kbn/observability-plugin/server';
import {
ALERT_RULE_PRODUCER,
ALERT_STATUS,
ALERT_STATUS_ACTIVE,
ALERT_UUID,
} from '@kbn/rule-data-utils';
import { SERVICE_NAME } from '../../../../common/es_fields/apm';
import { ServiceGroup } from '../../../../common/service_groups';
import { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client';
import { serviceGroupQuery } from '../../../lib/service_group_query';
interface ServiceAggResponse {
buckets: Array<
AggregationsFilterAggregate & {
key: string;
alerts_count: AggregationsCardinalityAggregate;
}
>;
}
export async function getServicesAlerts({
apmAlertsClient,
kuery,
maxNumServices,
serviceGroup,
}: {
apmAlertsClient: ApmAlertsClient;
kuery: string;
maxNumServices: number;
serviceGroup: ServiceGroup | null;
}) {
const params = {
size: 0,
query: {
bool: {
filter: [
{ term: { [ALERT_RULE_PRODUCER]: 'apm' } },
{ term: { [ALERT_STATUS]: ALERT_STATUS_ACTIVE } },
...kqlQuery(kuery),
...serviceGroupQuery(serviceGroup),
],
},
},
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: maxNumServices,
},
aggs: {
alerts_count: {
cardinality: {
field: ALERT_UUID,
},
},
},
},
},
};
const result = await apmAlertsClient.search(params);
const { buckets: filterAggBuckets } = (result.aggregations?.services ?? {
buckets: [],
}) as ServiceAggResponse;
const servicesAlertsCount: Array<{
serviceName: string;
alertsCount: number;
}> = filterAggBuckets.map((bucket) => ({
serviceName: bucket.key as string,
alertsCount: bucket.alerts_count.value,
}));
return servicesAlertsCount;
}

View file

@ -16,6 +16,8 @@ import { mergeServiceStats } from './merge_service_stats';
import { ServiceGroup } from '../../../../common/service_groups';
import { RandomSampler } from '../../../lib/helpers/get_random_sampler';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
import { getServicesAlerts } from './get_service_alerts';
import { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client';
export const MAX_NUMBER_OF_SERVICES = 500;
@ -24,6 +26,7 @@ export async function getServicesItems({
kuery,
mlClient,
apmEventClient,
apmAlertsClient,
searchAggregatedTransactions,
searchAggregatedServiceMetrics,
logger,
@ -36,6 +39,7 @@ export async function getServicesItems({
kuery: string;
mlClient?: MlClient;
apmEventClient: APMEventClient;
apmAlertsClient: ApmAlertsClient;
searchAggregatedTransactions: boolean;
searchAggregatedServiceMetrics: boolean;
logger: Logger;
@ -61,6 +65,7 @@ export async function getServicesItems({
transactionStats,
servicesFromErrorAndMetricDocuments,
healthStatuses,
alertCounts,
] = await Promise.all([
searchAggregatedServiceMetrics
? getServiceAggregatedTransactionStats({
@ -79,12 +84,17 @@ export async function getServicesItems({
logger.error(err);
return [];
}),
getServicesAlerts({ ...commonParams, apmAlertsClient }).catch((err) => {
logger.error(err);
return [];
}),
]);
return mergeServiceStats({
transactionStats,
servicesFromErrorAndMetricDocuments,
healthStatuses,
alertCounts,
});
});
}

View file

@ -12,12 +12,14 @@ import { getServicesItems } from './get_services_items';
import { ServiceGroup } from '../../../../common/service_groups';
import { RandomSampler } from '../../../lib/helpers/get_random_sampler';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
import { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client';
export async function getServices({
environment,
kuery,
mlClient,
apmEventClient,
apmAlertsClient,
searchAggregatedTransactions,
searchAggregatedServiceMetrics,
logger,
@ -30,6 +32,7 @@ export async function getServices({
kuery: string;
mlClient?: MlClient;
apmEventClient: APMEventClient;
apmAlertsClient: ApmAlertsClient;
searchAggregatedTransactions: boolean;
searchAggregatedServiceMetrics: boolean;
logger: Logger;
@ -44,6 +47,7 @@ export async function getServices({
kuery,
mlClient,
apmEventClient,
apmAlertsClient,
searchAggregatedTransactions,
searchAggregatedServiceMetrics,
logger,

View file

@ -53,6 +53,12 @@ describe('mergeServiceStats', () => {
serviceName: 'opbeans-java',
},
],
alertCounts: [
{
alertsCount: 1,
serviceName: 'opbeans-java',
},
],
})
).toEqual([
{
@ -73,6 +79,7 @@ describe('mergeServiceStats', () => {
throughput: 2,
transactionErrorRate: 3,
transactionType: 'request',
alertsCount: 1,
},
]);
});
@ -99,6 +106,12 @@ describe('mergeServiceStats', () => {
serviceName: 'opbeans-java',
},
],
alertCounts: [
{
alertsCount: 2,
serviceName: 'opbeans-java',
},
],
})
).toEqual([
{
@ -115,6 +128,7 @@ describe('mergeServiceStats', () => {
environments: ['production'],
healthStatus: ServiceHealthStatus.healthy,
serviceName: 'opbeans-java',
alertsCount: 2,
},
]);
});
@ -135,6 +149,12 @@ describe('mergeServiceStats', () => {
serviceName: 'opbeans-java',
},
],
alertCounts: [
{
alertsCount: 3,
serviceName: 'opbeans-java-2',
},
],
})
).toEqual([
{
@ -145,6 +165,7 @@ describe('mergeServiceStats', () => {
throughput: 2,
transactionErrorRate: 3,
transactionType: 'request',
alertsCount: 3,
},
]);
});
@ -166,6 +187,7 @@ describe('mergeServiceStats', () => {
},
],
healthStatuses: [],
alertCounts: [],
})
).toEqual([
{

View file

@ -7,6 +7,7 @@
import { uniq } from 'lodash';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { getServicesAlerts } from './get_service_alerts';
import { getHealthStatuses } from './get_health_statuses';
import { getServicesFromErrorAndMetricDocuments } from './get_services_from_error_and_metric_documents';
import { getServiceTransactionStats } from './get_service_transaction_stats';
@ -15,12 +16,14 @@ export function mergeServiceStats({
transactionStats,
servicesFromErrorAndMetricDocuments,
healthStatuses,
alertCounts,
}: {
transactionStats: Awaited<ReturnType<typeof getServiceTransactionStats>>;
servicesFromErrorAndMetricDocuments: Awaited<
ReturnType<typeof getServicesFromErrorAndMetricDocuments>
>;
healthStatuses: Awaited<ReturnType<typeof getHealthStatuses>>;
alertCounts: Awaited<ReturnType<typeof getServicesAlerts>>;
}) {
const foundServiceNames = transactionStats.map(
({ serviceName }) => serviceName
@ -46,6 +49,7 @@ export function mergeServiceStats({
...transactionStats,
...servicesFromErrorAndMetricDocuments,
...matchedHealthStatuses,
...alertCounts,
] as const),
'serviceName',
function merge(a, b) {

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
import {
inspectSearchParams,
SearchParamsMock,
} from '../../utils/test_helpers';
import { hasHistoricalAgentData } from '../historical_data/has_historical_agent_data';
import { getServicesItems } from './get_services/get_services_items';
import { getServiceAgent } from './get_service_agent';
import { getServiceTransactionTypes } from './get_service_transaction_types';
import { getServicesItems } from './get_services/get_services_items';
import { hasHistoricalAgentData } from '../historical_data/has_historical_agent_data';
import {
SearchParamsMock,
inspectSearchParams,
} from '../../utils/test_helpers';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
describe('services queries', () => {
let mock: SearchParamsMock;
@ -50,23 +50,25 @@ describe('services queries', () => {
});
it('fetches the service items', async () => {
mock = await inspectSearchParams(({ mockApmEventClient }) =>
getServicesItems({
mlClient: undefined,
apmEventClient: mockApmEventClient,
searchAggregatedTransactions: false,
searchAggregatedServiceMetrics: false,
logger: {} as any,
environment: ENVIRONMENT_ALL.value,
kuery: '',
start: 0,
end: 50000,
serviceGroup: null,
randomSampler: {
probability: 1,
seed: 0,
},
})
mock = await inspectSearchParams(
({ mockApmEventClient, mockApmAlertsClient }) =>
getServicesItems({
mlClient: undefined,
apmEventClient: mockApmEventClient,
searchAggregatedTransactions: false,
searchAggregatedServiceMetrics: false,
logger: {} as any,
environment: ENVIRONMENT_ALL.value,
kuery: '',
start: 0,
end: 50000,
serviceGroup: null,
randomSampler: {
probability: 1,
seed: 0,
},
apmAlertsClient: mockApmAlertsClient,
})
);
const allParams = mock.spy.mock.calls.map((call) => call[1]);

View file

@ -56,6 +56,7 @@ import { offsetRt } from '../../../common/comparison_rt';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { createInfraMetricsClient } from '../../lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { getApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client';
const servicesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services',
@ -88,6 +89,10 @@ const servicesRoute = createApmServerRoute({
| {
serviceName: string;
healthStatus: import('./../../../common/service_health_status').ServiceHealthStatus;
}
| {
serviceName: string;
alertsCount: number;
},
{
serviceName: string;
@ -104,6 +109,9 @@ const servicesRoute = createApmServerRoute({
} & {
serviceName: string;
healthStatus: import('./../../../common/service_health_status').ServiceHealthStatus;
} & {
serviceName: string;
alertsCount: number;
}
>;
}> {
@ -126,15 +134,21 @@ const servicesRoute = createApmServerRoute({
} = params.query;
const savedObjectsClient = (await context.core).savedObjects.client;
const [mlClient, apmEventClient, serviceGroup, randomSampler] =
await Promise.all([
getMlClient(resources),
getApmEventClient(resources),
serviceGroupId
? getServiceGroup({ savedObjectsClient, serviceGroupId })
: Promise.resolve(null),
getRandomSampler({ security, request, probability }),
]);
const [
mlClient,
apmEventClient,
apmAlertsClient,
serviceGroup,
randomSampler,
] = await Promise.all([
getMlClient(resources),
getApmEventClient(resources),
getApmAlertsClient(resources),
serviceGroupId
? getServiceGroup({ savedObjectsClient, serviceGroupId })
: Promise.resolve(null),
getRandomSampler({ security, request, probability }),
]);
const { searchAggregatedTransactions, searchAggregatedServiceMetrics } =
await getServiceInventorySearchSource({
@ -151,6 +165,7 @@ const servicesRoute = createApmServerRoute({
kuery,
mlClient,
apmEventClient,
apmAlertsClient,
searchAggregatedTransactions,
searchAggregatedServiceMetrics,
logger,

View file

@ -9,6 +9,7 @@ import type { ESSearchRequest, ESSearchResponse } from '@kbn/es-types';
import { APMConfig } from '..';
import { APMEventClient } from '../lib/helpers/create_es_client/create_apm_event_client';
import { APMInternalESClient } from '../lib/helpers/create_es_client/create_internal_es_client';
import { ApmAlertsClient } from '../lib/helpers/get_apm_alerts_client';
import { ApmIndicesConfig } from '../routes/settings/apm_indices/get_apm_indices';
interface Options {
@ -24,11 +25,13 @@ export async function inspectSearchParams(
mockConfig,
mockInternalESClient,
mockIndices,
mockApmAlertsClient,
}: {
mockApmEventClient: APMEventClient;
mockConfig: APMConfig;
mockInternalESClient: APMInternalESClient;
mockIndices: ApmIndicesConfig;
mockApmAlertsClient: ApmAlertsClient;
}) => Promise<any>,
options: Options = {}
) {
@ -85,6 +88,7 @@ export async function inspectSearchParams(
}
) as APMConfig;
const mockInternalESClient = { search: spy } as any;
const mockApmAlertsClient = { search: spy } as any;
try {
response = await fn({
@ -92,6 +96,7 @@ export async function inspectSearchParams(
mockApmEventClient,
mockConfig,
mockInternalESClient,
mockApmAlertsClient,
});
} catch (err) {
error = err;