[APM] Make optimised loading of service inventory opt-in (#128471)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2022-03-28 18:02:45 +02:00 committed by GitHub
parent 7494a91a1f
commit 56c65bc9ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 420 additions and 58 deletions

View file

@ -18,3 +18,13 @@ export interface ServiceListItem {
transactionErrorRate?: number | null;
environments?: string[];
}
export enum ServiceInventoryFieldName {
ServiceName = 'serviceName',
HealthStatus = 'healthStatus',
Environments = 'environments',
TransactionType = 'transactionType',
Throughput = 'throughput',
Latency = 'latency',
TransactionErrorRate = 'transactionErrorRate',
}

View file

@ -18,6 +18,10 @@ import { SearchBar } from '../../shared/search_bar';
import { ServiceList } from './service_list';
import { MLCallout, shouldDisplayMlCallout } from '../../shared/ml_callout';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { apmServiceInventoryOptimizedSorting } from '../../../../../observability/common';
import { ServiceInventoryFieldName } from '../../../../common/service_inventory';
import { orderServiceItems } from './service_list/order_service_items';
const initialData = {
requestId: '',
@ -157,10 +161,36 @@ export function ServiceInventory() {
/>
);
const mainStatisticsItems = mainStatisticsFetch.data?.items ?? [];
const preloadedServices = sortedAndFilteredServicesFetch.data?.services || [];
const displayHealthStatus = [
...mainStatisticsItems,
...preloadedServices,
].some((item) => 'healthStatus' in item);
const tiebreakerField = useKibana().services.uiSettings?.get<boolean>(
apmServiceInventoryOptimizedSorting
)
? ServiceInventoryFieldName.ServiceName
: ServiceInventoryFieldName.Throughput;
const initialSortField = displayHealthStatus
? ServiceInventoryFieldName.HealthStatus
: tiebreakerField;
const initialSortDirection =
initialSortField === ServiceInventoryFieldName.ServiceName ? 'asc' : 'desc';
const items = joinByKey(
[
...(sortedAndFilteredServicesFetch.data?.services ?? []),
...(mainStatisticsFetch.data?.items ?? []),
// only use preloaded services if tiebreaker field is service.name,
// otherwise ignore them to prevent re-sorting of the table
// once the tiebreaking metric comes in
...(tiebreakerField === ServiceInventoryFieldName.ServiceName
? preloadedServices
: []),
...mainStatisticsItems,
],
'serviceName'
);
@ -187,6 +217,17 @@ export function ServiceInventory() {
comparisonFetch.status === FETCH_STATUS.LOADING ||
comparisonFetch.status === FETCH_STATUS.NOT_INITIATED
}
displayHealthStatus={displayHealthStatus}
initialSortField={initialSortField}
initialSortDirection={initialSortDirection}
sortFn={(itemsToSort, sortField, sortDirection) => {
return orderServiceItems({
items: itemsToSort,
primarySortField: sortField,
sortDirection,
tiebreakerField,
});
}}
comparisonData={comparisonFetch?.data}
noItemsMessage={noItemsMessage}
/>

View file

@ -15,7 +15,6 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TypeOf } from '@kbn/typed-react-router-config';
import { orderBy } from 'lodash';
import React, { useMemo } from 'react';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { ServiceHealthStatus } from '../../../../../common/service_health_status';
@ -45,7 +44,10 @@ import {
getTimeSeriesColor,
} from '../../../shared/charts/helper/get_timeseries_color';
import { HealthBadge } from './health_badge';
import { ServiceListItem } from '../../../../../common/service_inventory';
import {
ServiceInventoryFieldName,
ServiceListItem,
} from '../../../../../common/service_inventory';
type ServicesDetailedStatisticsAPIResponse =
APIReturnType<'GET /internal/apm/services/detailed_statistics'>;
@ -54,13 +56,6 @@ function formatString(value?: string | null) {
return value || NOT_AVAILABLE_LABEL;
}
const SERVICE_HEALTH_STATUS_ORDER = [
ServiceHealthStatus.unknown,
ServiceHealthStatus.healthy,
ServiceHealthStatus.warning,
ServiceHealthStatus.critical,
];
export function getServiceColumns({
query,
showTransactionTypeColumn,
@ -84,7 +79,7 @@ export function getServiceColumns({
...(showHealthStatusColumn
? [
{
field: 'healthStatus',
field: ServiceInventoryFieldName.HealthStatus,
name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', {
defaultMessage: 'Health',
}),
@ -101,7 +96,7 @@ export function getServiceColumns({
]
: []),
{
field: 'serviceName',
field: ServiceInventoryFieldName.ServiceName,
name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', {
defaultMessage: 'Name',
}),
@ -123,7 +118,7 @@ export function getServiceColumns({
...(showWhenSmallOrGreaterThanLarge
? [
{
field: 'environments',
field: ServiceInventoryFieldName.Environments,
name: i18n.translate(
'xpack.apm.servicesTable.environmentColumnLabel',
{
@ -141,7 +136,7 @@ export function getServiceColumns({
...(showTransactionTypeColumn && showWhenSmallOrGreaterThanXL
? [
{
field: 'transactionType',
field: ServiceInventoryFieldName.TransactionType,
name: i18n.translate(
'xpack.apm.servicesTable.transactionColumnLabel',
{ defaultMessage: 'Transaction type' }
@ -152,7 +147,7 @@ export function getServiceColumns({
]
: []),
{
field: 'latency',
field: ServiceInventoryFieldName.Latency,
name: i18n.translate('xpack.apm.servicesTable.latencyAvgColumnLabel', {
defaultMessage: 'Latency (avg.)',
}),
@ -179,7 +174,7 @@ export function getServiceColumns({
align: RIGHT_ALIGNMENT,
},
{
field: 'throughput',
field: ServiceInventoryFieldName.Throughput,
name: i18n.translate('xpack.apm.servicesTable.throughputColumnLabel', {
defaultMessage: 'Throughput',
}),
@ -207,7 +202,7 @@ export function getServiceColumns({
align: RIGHT_ALIGNMENT,
},
{
field: 'transactionErrorRate',
field: ServiceInventoryFieldName.TransactionErrorRate,
name: i18n.translate('xpack.apm.servicesTable.transactionErrorRate', {
defaultMessage: 'Failed transaction rate',
}),
@ -246,6 +241,14 @@ interface Props {
noItemsMessage?: React.ReactNode;
isLoading: boolean;
isFailure?: boolean;
displayHealthStatus: boolean;
initialSortField: ServiceInventoryFieldName;
initialSortDirection: 'asc' | 'desc';
sortFn: (
sortItems: ServiceListItem[],
sortField: ServiceInventoryFieldName,
sortDirection: 'asc' | 'desc'
) => ServiceListItem[];
}
export function ServiceList({
@ -255,9 +258,12 @@ export function ServiceList({
comparisonData,
isLoading,
isFailure,
displayHealthStatus,
initialSortField,
initialSortDirection,
sortFn,
}: Props) {
const breakpoints = useBreakpoints();
const displayHealthStatus = items.some((item) => 'healthStatus' in item);
const showTransactionTypeColumn = items.some(
({ transactionType }) =>
@ -292,9 +298,6 @@ export function ServiceList({
]
);
const initialSortField = displayHealthStatus ? 'healthStatus' : 'serviceName';
const initialSortDirection = displayHealthStatus ? 'desc' : 'asc';
return (
<EuiFlexGroup gutterSize="xs" direction="column" responsive={false}>
<EuiFlexItem>
@ -333,7 +336,7 @@ export function ServiceList({
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<ManagedTable
<ManagedTable<ServiceListItem>
isLoading={isLoading}
error={isFailure}
columns={serviceColumns}
@ -341,41 +344,13 @@ export function ServiceList({
noItemsMessage={noItemsMessage}
initialSortField={initialSortField}
initialSortDirection={initialSortDirection}
sortFn={(itemsToSort, sortField, sortDirection) => {
// For healthStatus, sort items by healthStatus first, then by name
return sortField === 'healthStatus'
? orderBy(
itemsToSort,
[
(item) => {
return item.healthStatus
? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus)
: -1;
},
(item) => item.serviceName.toLowerCase(),
],
[sortDirection, sortDirection === 'asc' ? 'desc' : 'asc']
)
: orderBy(
itemsToSort,
(item) => {
switch (sortField) {
// Use `?? -1` here so `undefined` will appear after/before `0`.
// In the table this will make the "N/A" items always at the
// bottom/top.
case 'latency':
return item.latency ?? -1;
case 'throughput':
return item.throughput ?? -1;
case 'transactionErrorRate':
return item.transactionErrorRate ?? -1;
default:
return item[sortField as keyof typeof item];
}
},
sortDirection
);
}}
sortFn={(itemsToSort, sortField, sortDirection) =>
sortFn(
itemsToSort,
sortField as ServiceInventoryFieldName,
sortDirection
)
}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,230 @@
/*
* 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 { ServiceHealthStatus } from '../../../../../common/service_health_status';
import { ServiceInventoryFieldName } from '../../../../../common/service_inventory';
import { orderServiceItems } from './order_service_items';
describe('orderServiceItems', () => {
describe('when sorting by health status', () => {
describe('desc', () => {
it('orders from critical to unknown', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.HealthStatus,
sortDirection: 'desc',
tiebreakerField: ServiceInventoryFieldName.Throughput,
items: [
{
serviceName: 'critical-service',
healthStatus: ServiceHealthStatus.critical,
},
{
serviceName: 'healthy-service',
healthStatus: ServiceHealthStatus.healthy,
},
{
serviceName: 'warning-service',
healthStatus: ServiceHealthStatus.warning,
},
{
serviceName: 'unknown-service',
healthStatus: ServiceHealthStatus.unknown,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'critical-service',
'warning-service',
'healthy-service',
'unknown-service',
]);
});
it('sorts by service name ascending as a tie-breaker', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.HealthStatus,
sortDirection: 'desc',
tiebreakerField: ServiceInventoryFieldName.ServiceName,
items: [
{
serviceName: 'b-critical-service',
healthStatus: ServiceHealthStatus.critical,
},
{
serviceName: 'a-critical-service',
healthStatus: ServiceHealthStatus.critical,
},
{
serviceName: 'a-unknown-service',
healthStatus: ServiceHealthStatus.unknown,
},
{
serviceName: 'b-unknown-service',
healthStatus: ServiceHealthStatus.unknown,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'a-critical-service',
'b-critical-service',
'a-unknown-service',
'b-unknown-service',
]);
});
it('sorts by metric descending as a tie-breaker', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.HealthStatus,
sortDirection: 'desc',
tiebreakerField: ServiceInventoryFieldName.Throughput,
items: [
{
serviceName: 'low-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 1,
},
{
serviceName: 'high-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 100,
},
{
serviceName: 'med-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 10,
},
{
serviceName: 'critical-service',
healthStatus: ServiceHealthStatus.critical,
throughput: 0,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'critical-service',
'high-throughput-service',
'med-throughput-service',
'low-throughput-service',
]);
});
});
describe('asc', () => {
it('orders from unknown to critical', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.HealthStatus,
sortDirection: 'asc',
tiebreakerField: ServiceInventoryFieldName.Throughput,
items: [
{
serviceName: 'critical-service',
healthStatus: ServiceHealthStatus.critical,
},
{
serviceName: 'healthy-service',
healthStatus: ServiceHealthStatus.healthy,
},
{
serviceName: 'warning-service',
healthStatus: ServiceHealthStatus.warning,
},
{
serviceName: 'unknown-service',
healthStatus: ServiceHealthStatus.unknown,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'unknown-service',
'healthy-service',
'warning-service',
'critical-service',
]);
});
});
});
describe('when sorting by metric fields', () => {
it('sorts correctly', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.Throughput,
sortDirection: 'desc',
tiebreakerField: ServiceInventoryFieldName.Throughput,
items: [
{
serviceName: 'low-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 1,
},
{
serviceName: 'high-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 100,
},
{
serviceName: 'med-throughput-service',
healthStatus: ServiceHealthStatus.unknown,
throughput: 10,
},
{
serviceName: 'critical-service',
healthStatus: ServiceHealthStatus.critical,
throughput: 0,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'high-throughput-service',
'med-throughput-service',
'low-throughput-service',
'critical-service',
]);
});
});
describe('when sorting by alphabetical fields', () => {
const sortedItems = orderServiceItems({
primarySortField: ServiceInventoryFieldName.ServiceName,
sortDirection: 'asc',
tiebreakerField: ServiceInventoryFieldName.ServiceName,
items: [
{
serviceName: 'd-service',
healthStatus: ServiceHealthStatus.unknown,
},
{
serviceName: 'a-service',
healthStatus: ServiceHealthStatus.unknown,
},
{
serviceName: 'b-service',
healthStatus: ServiceHealthStatus.unknown,
},
{
serviceName: 'c-service',
healthStatus: ServiceHealthStatus.unknown,
},
{
serviceName: '0-service',
healthStatus: ServiceHealthStatus.unknown,
},
],
});
expect(sortedItems.map((item) => item.serviceName)).toEqual([
'0-service',
'a-service',
'b-service',
'c-service',
'd-service',
]);
});
});

View file

@ -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 { orderBy } from 'lodash';
import { ServiceHealthStatus } from '../../../../../common/service_health_status';
import {
ServiceListItem,
ServiceInventoryFieldName,
} from '../../../../../common/service_inventory';
type SortValueGetter = (item: ServiceListItem) => string | number;
const SERVICE_HEALTH_STATUS_ORDER = [
ServiceHealthStatus.unknown,
ServiceHealthStatus.healthy,
ServiceHealthStatus.warning,
ServiceHealthStatus.critical,
];
const sorts: Record<ServiceInventoryFieldName, SortValueGetter> = {
[ServiceInventoryFieldName.HealthStatus]: (item) =>
item.healthStatus
? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus)
: -1,
[ServiceInventoryFieldName.ServiceName]: (item) =>
item.serviceName.toLowerCase(),
[ServiceInventoryFieldName.Environments]: (item) =>
item.environments?.join(', ').toLowerCase() ?? '',
[ServiceInventoryFieldName.TransactionType]: (item) =>
item.transactionType ?? '',
[ServiceInventoryFieldName.Latency]: (item) => item.latency ?? -1,
[ServiceInventoryFieldName.Throughput]: (item) => item.throughput ?? -1,
[ServiceInventoryFieldName.TransactionErrorRate]: (item) =>
item.transactionErrorRate ?? -1,
};
function reverseSortDirection(sortDirection: 'asc' | 'desc') {
return sortDirection === 'asc' ? 'desc' : 'asc';
}
export function orderServiceItems({
items,
primarySortField,
tiebreakerField,
sortDirection,
}: {
items: ServiceListItem[];
primarySortField: ServiceInventoryFieldName;
tiebreakerField: ServiceInventoryFieldName;
sortDirection: 'asc' | 'desc';
}): ServiceListItem[] {
// For healthStatus, sort items by healthStatus first, then by tie-breaker
const sortFn = sorts[primarySortField as ServiceInventoryFieldName];
if (primarySortField === ServiceInventoryFieldName.HealthStatus) {
const tiebreakerSortDirection =
tiebreakerField === ServiceInventoryFieldName.ServiceName
? reverseSortDirection(sortDirection)
: sortDirection;
const tiebreakerSortFn = sorts[tiebreakerField];
return orderBy(
items,
[sortFn, tiebreakerSortFn],
[sortDirection, tiebreakerSortDirection]
);
}
return orderBy(items, sortFn, sortDirection);
}

View file

@ -11,6 +11,7 @@ import { MemoryRouter } from 'react-router-dom';
import { CoreStart } from '../../../../../../../../src/core/public';
import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public';
import { ServiceHealthStatus } from '../../../../../common/service_health_status';
import { ServiceInventoryFieldName } from '../../../../../common/service_inventory';
import type { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context';
import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context';
import { ServiceList } from './';
@ -59,6 +60,10 @@ export const Example: Story<Args> = (args) => {
Example.args = {
isLoading: false,
items,
displayHealthStatus: true,
initialSortField: ServiceInventoryFieldName.HealthStatus,
initialSortDirection: 'desc',
sortFn: (sortItems) => sortItems,
};
export const EmptyState: Story<Args> = (args) => {
@ -67,6 +72,10 @@ export const EmptyState: Story<Args> = (args) => {
EmptyState.args = {
isLoading: false,
items: [],
displayHealthStatus: true,
initialSortField: ServiceInventoryFieldName.HealthStatus,
initialSortDirection: 'desc',
sortFn: (sortItems) => sortItems,
};
export const WithHealthWarnings: Story<Args> = (args) => {

View file

@ -15,6 +15,7 @@ export {
enableComparisonByDefault,
enableInfrastructureView,
defaultApmServiceEnvironment,
apmServiceInventoryOptimizedSorting,
} from './ui_settings_keys';
export const casesFeatureId = 'observabilityCases';

View file

@ -11,3 +11,5 @@ export const enableComparisonByDefault = 'observability:enableComparisonByDefaul
export const enableInfrastructureView = 'observability:enableInfrastructureView';
export const defaultApmServiceEnvironment = 'observability:apmDefaultServiceEnvironment';
export const enableServiceGroups = 'observability:enableServiceGroups';
export const apmServiceInventoryOptimizedSorting =
'observability:apmServiceInventoryOptimizedSorting';

View file

@ -16,6 +16,7 @@ import {
enableInfrastructureView,
defaultApmServiceEnvironment,
enableServiceGroups,
apmServiceInventoryOptimizedSorting,
} from '../common/ui_settings_keys';
const technicalPreviewLabel = i18n.translate(
@ -98,4 +99,22 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
schema: schema.boolean(),
requiresPageReload: true,
},
[apmServiceInventoryOptimizedSorting]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.apmServiceInventoryOptimizedSorting', {
defaultMessage: 'Optimize APM Service Inventory page load performance',
}),
description: i18n.translate(
'xpack.observability.apmServiceInventoryOptimizedSortingDescription',
{
defaultMessage:
'{technicalPreviewLabel} Default APM Service Inventory page sort (for Services without Machine Learning applied) to sort by Service Name',
values: { technicalPreviewLabel: `<em>[${technicalPreviewLabel}]</em>` },
}
),
schema: schema.boolean(),
value: false,
requiresPageReload: false,
type: 'boolean',
},
};