[7.x] [APM] Service inventory redesign (#76744) (#77353)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2020-09-15 10:32:46 +02:00 committed by GitHub
parent 0eac8ae37f
commit d8a0688e52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 1893 additions and 524 deletions

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getSeverity, severity } from './getSeverity';
import { getSeverity, Severity } from './anomaly_detection';
describe('getSeverity', () => {
describe('when score is undefined', () => {
@ -15,25 +15,25 @@ describe('getSeverity', () => {
describe('when score < 25', () => {
it('returns warning', () => {
expect(getSeverity(10)).toEqual(severity.warning);
expect(getSeverity(10)).toEqual(Severity.warning);
});
});
describe('when score is between 25 and 50', () => {
it('returns minor', () => {
expect(getSeverity(40)).toEqual(severity.minor);
expect(getSeverity(40)).toEqual(Severity.minor);
});
});
describe('when score is between 50 and 75', () => {
it('returns major', () => {
expect(getSeverity(60)).toEqual(severity.major);
expect(getSeverity(60)).toEqual(Severity.major);
});
});
describe('when score is 75 or more', () => {
it('returns critical', () => {
expect(getSeverity(100)).toEqual(severity.critical);
expect(getSeverity(100)).toEqual(Severity.critical);
});
});
});

View file

@ -5,6 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
import { EuiTheme } from '../../../legacy/common/eui_styled_components';
export interface ServiceAnomalyStats {
transactionType?: string;
@ -13,6 +14,82 @@ export interface ServiceAnomalyStats {
jobId?: string;
}
export enum Severity {
critical = 'critical',
major = 'major',
minor = 'minor',
warning = 'warning',
}
// TODO: Replace with `getSeverity` from:
// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129
export function getSeverity(score?: number) {
if (typeof score !== 'number') {
return undefined;
} else if (score < 25) {
return Severity.warning;
} else if (score >= 25 && score < 50) {
return Severity.minor;
} else if (score >= 50 && score < 75) {
return Severity.major;
} else if (score >= 75) {
return Severity.critical;
} else {
return undefined;
}
}
export function getSeverityColor(theme: EuiTheme, severity?: Severity) {
switch (severity) {
case Severity.warning:
return theme.eui.euiColorVis0;
case Severity.minor:
case Severity.major:
return theme.eui.euiColorVis5;
case Severity.critical:
return theme.eui.euiColorVis9;
default:
return;
}
}
export function getSeverityLabel(severity?: Severity) {
switch (severity) {
case Severity.critical:
return i18n.translate(
'xpack.apm.servicesTable.serviceHealthStatus.critical',
{
defaultMessage: 'Critical',
}
);
case Severity.major:
case Severity.minor:
return i18n.translate(
'xpack.apm.servicesTable.serviceHealthStatus.warning',
{
defaultMessage: 'Warning',
}
);
case Severity.warning:
return i18n.translate(
'xpack.apm.servicesTable.serviceHealthStatus.healthy',
{
defaultMessage: 'Healthy',
}
);
default:
return i18n.translate(
'xpack.apm.servicesTable.serviceHealthStatus.unknown',
{
defaultMessage: 'Unknown',
}
);
}
}
export const ML_ERRORS = {
INVALID_LICENSE: i18n.translate(
'xpack.apm.anomaly_detection.error.invalid_license',

View file

@ -8,7 +8,7 @@ import { License } from '../../licensing/common/license';
import * as serviceMap from './service_map';
describe('service map helpers', () => {
describe('isValidPlatinumLicense', () => {
describe('isActivePlatinumLicense', () => {
describe('with an expired license', () => {
it('returns false', () => {
const license = new License({
@ -22,7 +22,7 @@ describe('service map helpers', () => {
signature: 'test signature',
});
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false);
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false);
});
});
@ -39,7 +39,7 @@ describe('service map helpers', () => {
signature: 'test signature',
});
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false);
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false);
});
});
@ -56,7 +56,7 @@ describe('service map helpers', () => {
signature: 'test signature',
});
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
});
});
@ -73,7 +73,7 @@ describe('service map helpers', () => {
signature: 'test signature',
});
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
});
});
@ -90,7 +90,7 @@ describe('service map helpers', () => {
signature: 'test signature',
});
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
});
});
});

View file

@ -46,7 +46,7 @@ export interface ServiceNodeStats {
avgErrorRate: number | null;
}
export function isValidPlatinumLicense(license: ILicense) {
export function isActivePlatinumLicense(license: ILicense) {
return license.isActive && license.hasAtLeast('platinum');
}

View file

@ -18,10 +18,13 @@ import { useTheme } from '../../../../hooks/useTheme';
import { fontSize, px } from '../../../../style/variables';
import { asInteger, asDuration } from '../../../../utils/formatters';
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
import { getSeverityColor, popoverWidth } from '../cytoscapeOptions';
import { popoverWidth } from '../cytoscapeOptions';
import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types';
import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection';
import { getSeverity } from './getSeverity';
import {
getSeverity,
getSeverityColor,
ServiceAnomalyStats,
} from '../../../../../common/anomaly_detection';
const HealthStatusTitle = styled(EuiTitle)`
display: inline;

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export enum severity {
critical = 'critical',
major = 'major',
minor = 'minor',
warning = 'warning',
}
// TODO: Replace with `getSeverity` from:
// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129
export function getSeverity(score?: number) {
if (typeof score !== 'number') {
return undefined;
} else if (score < 25) {
return severity.warning;
} else if (score >= 25 && score < 50) {
return severity.minor;
} else if (score >= 50 && score < 75) {
return severity.major;
} else if (score >= 75) {
return severity.critical;
} else {
return undefined;
}
}

View file

@ -11,25 +11,15 @@ import {
} from '../../../../common/elasticsearch_fieldnames';
import { EuiTheme } from '../../../../../observability/public';
import { defaultIcon, iconForNode } from './icons';
import { ServiceAnomalyStats } from '../../../../common/anomaly_detection';
import { severity, getSeverity } from './Popover/getSeverity';
import {
getSeverity,
getSeverityColor,
ServiceAnomalyStats,
Severity,
} from '../../../../common/anomaly_detection';
export const popoverWidth = 280;
export function getSeverityColor(theme: EuiTheme, nodeSeverity?: string) {
switch (nodeSeverity) {
case severity.warning:
return theme.eui.euiColorVis0;
case severity.minor:
case severity.major:
return theme.eui.euiColorVis5;
case severity.critical:
return theme.eui.euiColorVis9;
default:
return;
}
}
function getNodeSeverity(el: cytoscape.NodeSingular) {
const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data(
'serviceAnomalyStats'
@ -60,7 +50,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction<
cytoscape.Css.LineStyle
> = (el: cytoscape.NodeSingular) => {
const nodeSeverity = getNodeSeverity(el);
if (nodeSeverity === severity.critical) {
if (nodeSeverity === Severity.critical) {
return 'double';
} else {
return 'solid';
@ -70,9 +60,9 @@ const getBorderStyle: cytoscape.Css.MapperFunction<
function getBorderWidth(el: cytoscape.NodeSingular) {
const nodeSeverity = getNodeSeverity(el);
if (nodeSeverity === severity.minor || nodeSeverity === severity.major) {
if (nodeSeverity === Severity.minor || nodeSeverity === Severity.major) {
return 4;
} else if (nodeSeverity === severity.critical) {
} else if (nodeSeverity === Severity.critical) {
return 8;
} else {
return 4;

View file

@ -5,7 +5,6 @@
*/
import cytoscape from 'cytoscape';
import { getNormalizedAgentName } from '../../../../common/agent_name';
import {
AGENT_NAME,
SPAN_SUBTYPE,
@ -13,29 +12,22 @@ import {
} from '../../../../common/elasticsearch_fieldnames';
import awsIcon from './icons/aws.svg';
import cassandraIcon from './icons/cassandra.svg';
import darkIcon from './icons/dark.svg';
import databaseIcon from './icons/database.svg';
import defaultIconImport from './icons/default.svg';
import documentsIcon from './icons/documents.svg';
import dotNetIcon from './icons/dot-net.svg';
import elasticsearchIcon from './icons/elasticsearch.svg';
import globeIcon from './icons/globe.svg';
import goIcon from './icons/go.svg';
import graphqlIcon from './icons/graphql.svg';
import grpcIcon from './icons/grpc.svg';
import handlebarsIcon from './icons/handlebars.svg';
import javaIcon from './icons/java.svg';
import kafkaIcon from './icons/kafka.svg';
import mongodbIcon from './icons/mongodb.svg';
import mysqlIcon from './icons/mysql.svg';
import nodeJsIcon from './icons/nodejs.svg';
import phpIcon from './icons/php.svg';
import postgresqlIcon from './icons/postgresql.svg';
import pythonIcon from './icons/python.svg';
import redisIcon from './icons/redis.svg';
import rubyIcon from './icons/ruby.svg';
import rumJsIcon from './icons/rumjs.svg';
import websocketIcon from './icons/websocket.svg';
import javaIcon from '../../shared/AgentIcon/icons/java.svg';
import { getAgentIcon } from '../../shared/AgentIcon/get_agent_icon';
export const defaultIcon = defaultIconImport;
@ -74,23 +66,6 @@ const typeIcons: { [key: string]: { [key: string]: string } } = {
},
};
const agentIcons: { [key: string]: string } = {
dark: darkIcon,
dotnet: dotNetIcon,
go: goIcon,
java: javaIcon,
'js-base': rumJsIcon,
nodejs: nodeJsIcon,
php: phpIcon,
python: pythonIcon,
ruby: rubyIcon,
};
function getAgentIcon(agentName?: string) {
const normalizedAgentName = getNormalizedAgentName(agentName);
return normalizedAgentName && agentIcons[normalizedAgentName];
}
function getSpanIcon(type?: string, subtype?: string) {
if (!type) {
return;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

View file

@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useTheme } from '../../../hooks/useTheme';
import {
invalidLicenseMessage,
isValidPlatinumLicense,
isActivePlatinumLicense,
} from '../../../../common/service_map';
import { useFetcher } from '../../../hooks/useFetcher';
import { useLicense } from '../../../hooks/useLicense';
@ -36,7 +36,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
const { data = { elements: [] } } = useFetcher(() => {
// When we don't have a license or a valid license, don't make the request.
if (!license || !isValidPlatinumLicense(license)) {
if (!license || !isActivePlatinumLicense(license)) {
return;
}
@ -66,7 +66,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
return null;
}
return isValidPlatinumLicense(license) ? (
return isActivePlatinumLicense(license) ? (
<div
style={{
height: height - parseInt(theme.eui.gutterTypes.gutterLarge, 10),

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import {
getSeverityColor,
getSeverityLabel,
Severity,
} from '../../../../../common/anomaly_detection';
import { useTheme } from '../../../../hooks/useTheme';
export function HealthBadge({ severity }: { severity?: Severity }) {
const theme = useTheme();
const unknownColor = theme.eui.euiColorLightShade;
return (
<EuiBadge color={getSeverityColor(theme, severity) ?? unknownColor}>
{getSeverityLabel(severity)}
</EuiBadge>
);
}

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiButton } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiFlexGrid } from '@elastic/eui';
import { EuiButtonEmpty } from '@elastic/eui';
import { APMLink } from '../../../shared/Links/apm/APMLink';
export function MLCallout({ onDismiss }: { onDismiss: () => void }) {
return (
<EuiCallOut
title={i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.title', {
defaultMessage:
'Enable anomaly detection to see the health of your services',
})}
iconType="iInCircle"
>
<p>
{i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.content', {
defaultMessage: `Our integration with ML anomaly detection will enable you to see your services' health status`,
})}
</p>
<EuiFlexGrid gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton>
<APMLink
path="/settings/anomaly-detection"
style={{ whiteSpace: 'nowrap' }}
>
{i18n.translate(
'xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton',
{
defaultMessage: `Learn more`,
}
)}
</APMLink>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => onDismiss()}>
{i18n.translate(
'xpack.apm.serviceOverview.mlNudgeMessage.dismissButton',
{
defaultMessage: `Dismiss message`,
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGrid>
</EuiCallOut>
);
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import { useTheme } from '../../../../hooks/useTheme';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { getEmptySeries } from '../../../shared/charts/CustomPlot/getEmptySeries';
import { SparkPlot } from '../../../shared/charts/SparkPlot';
export function ServiceListMetric({
color,
series,
valueLabel,
}: {
color: 'euiColorVis1' | 'euiColorVis0' | 'euiColorVis7';
series?: Array<{ x: number; y: number | null }>;
valueLabel: React.ReactNode;
}) {
const theme = useTheme();
const {
urlParams: { start, end },
} = useUrlParams();
const colorValue = theme.eui[color];
return (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<SparkPlot
series={
series ??
getEmptySeries(parseFloat(start!), parseFloat(end!))[0].data
}
color={colorValue}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ whiteSpace: 'nowrap' }}>
{valueLabel}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -15,34 +15,62 @@ describe('ServiceOverview -> List', () => {
mockMoment();
});
it('should render empty state', () => {
it('renders empty state', () => {
const wrapper = shallow(<ServiceList items={[]} />);
expect(wrapper).toMatchSnapshot();
});
it('should render with data', () => {
it('renders with data', () => {
const wrapper = shallow(<ServiceList items={props.items} />);
expect(wrapper).toMatchSnapshot();
});
it('should render columns correctly', () => {
it('renders columns correctly', () => {
const service = {
serviceName: 'opbeans-python',
agentName: 'python',
transactionsPerMinute: 86.93333333333334,
errorsPerMinute: 12.6,
avgResponseTime: 91535.42944785276,
transactionsPerMinute: {
value: 86.93333333333334,
timeseries: [],
},
errorsPerMinute: {
value: 12.6,
timeseries: [],
},
avgResponseTime: {
value: 91535.42944785276,
timeseries: [],
},
environments: ['test'],
};
const renderedColumns = SERVICE_COLUMNS.map((c) =>
c.render(service[c.field], service)
);
expect(renderedColumns[0]).toMatchSnapshot();
expect(renderedColumns.slice(2)).toEqual([
'python',
'92 ms',
'86.9 tpm',
'12.6 err.',
]);
});
describe('without ML data', () => {
it('does not render health column', () => {
const wrapper = shallow(
<ServiceList items={props.items} displayHealthStatus={false} />
);
const columns = wrapper.props().columns;
expect(columns[0].field).not.toBe('severity');
});
});
describe('with ML data', () => {
it('renders health column', () => {
const wrapper = shallow(
<ServiceList items={props.items} displayHealthStatus />
);
const columns = wrapper.props().columns;
expect(columns[0].field).toBe('severity');
});
});
});

View file

@ -1,21 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ServiceOverview -> List should render columns correctly 1`] = `
<EuiToolTip
content="opbeans-python"
delay="regular"
id="service-name-tooltip"
position="top"
>
<Styled(TransactionOverviewLink)
serviceName="opbeans-python"
>
opbeans-python
</Styled(TransactionOverviewLink)>
</EuiToolTip>
`;
exports[`ServiceOverview -> List renders columns correctly 1`] = `<HealthBadge />`;
exports[`ServiceOverview -> List should render empty state 1`] = `
exports[`ServiceOverview -> List renders empty state 1`] = `
<Memo(UnoptimizedManagedTable)
columns={
Array [
@ -31,44 +18,46 @@ exports[`ServiceOverview -> List should render empty state 1`] = `
"name": "Environment",
"render": [Function],
"sortable": true,
"width": "20%",
},
Object {
"field": "agentName",
"name": "Agent",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "avgResponseTime",
"name": "Avg. response time",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "transactionsPerMinute",
"name": "Trans. per minute",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "errorsPerMinute",
"name": "Errors per minute",
"name": "Error rate %",
"render": [Function],
"sortable": true,
"width": "160px",
},
]
}
initialPageSize={50}
initialSortField="serviceName"
initialSortDirection="desc"
initialSortField="severity"
items={Array []}
sortFn={[Function]}
/>
`;
exports[`ServiceOverview -> List should render with data 1`] = `
exports[`ServiceOverview -> List renders with data 1`] = `
<Memo(UnoptimizedManagedTable)
columns={
Array [
@ -84,39 +73,40 @@ exports[`ServiceOverview -> List should render with data 1`] = `
"name": "Environment",
"render": [Function],
"sortable": true,
"width": "20%",
},
Object {
"field": "agentName",
"name": "Agent",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "avgResponseTime",
"name": "Avg. response time",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "transactionsPerMinute",
"name": "Trans. per minute",
"render": [Function],
"sortable": true,
"width": "160px",
},
Object {
"align": "left",
"dataType": "number",
"field": "errorsPerMinute",
"name": "Errors per minute",
"name": "Error rate %",
"render": [Function],
"sortable": true,
"width": "160px",
},
]
}
initialPageSize={50}
initialSortField="serviceName"
initialSortDirection="desc"
initialSortField="severity"
items={
Array [
Object {
@ -125,19 +115,35 @@ exports[`ServiceOverview -> List should render with data 1`] = `
"environments": Array [
"test",
],
"errorsPerMinute": 46.06666666666667,
"errorsPerMinute": Object {
"timeseries": Array [],
"value": 46.06666666666667,
},
"serviceName": "opbeans-node",
"transactionsPerMinute": 0,
"transactionsPerMinute": Object {
"timeseries": Array [],
"value": 0,
},
},
Object {
"agentName": "python",
"avgResponseTime": 91535.42944785276,
"avgResponseTime": Object {
"timeseries": Array [],
"value": 91535.42944785276,
},
"environments": Array [],
"errorsPerMinute": 12.6,
"errorsPerMinute": Object {
"timeseries": Array [],
"value": 12.6,
},
"serviceName": "opbeans-python",
"transactionsPerMinute": 86.93333333333334,
"transactionsPerMinute": Object {
"timeseries": Array [],
"value": 86.93333333333334,
},
},
]
}
sortFn={[Function]}
/>
`;

View file

@ -3,17 +3,34 @@
{
"serviceName": "opbeans-node",
"agentName": "nodejs",
"transactionsPerMinute": 0,
"errorsPerMinute": 46.06666666666667,
"transactionsPerMinute": {
"value": 0,
"timeseries": []
},
"errorsPerMinute": {
"value": 46.06666666666667,
"timeseries": []
},
"avgResponseTime": null,
"environments": ["test"]
"environments": [
"test"
]
},
{
"serviceName": "opbeans-python",
"agentName": "python",
"transactionsPerMinute": 86.93333333333334,
"errorsPerMinute": 12.6,
"avgResponseTime": 91535.42944785276,
"transactionsPerMinute": {
"value": 86.93333333333334,
"timeseries": []
},
"errorsPerMinute": {
"value": 12.6,
"timeseries": []
},
"avgResponseTime": {
"value": 91535.42944785276,
"timeseries": []
},
"environments": []
}
]

View file

@ -4,24 +4,34 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiToolTip } from '@elastic/eui';
import { EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import styled from 'styled-components';
import { ValuesType } from 'utility-types';
import { orderBy } from 'lodash';
import { asPercent } from '../../../../../common/utils/formatters';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { fontSizes, truncate } from '../../../../style/variables';
import { fontSizes, px, truncate, unit } from '../../../../style/variables';
import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters';
import { ManagedTable } from '../../../shared/ManagedTable';
import { ManagedTable, ITableColumn } from '../../../shared/ManagedTable';
import { EnvironmentBadge } from '../../../shared/EnvironmentBadge';
import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink';
import { AgentIcon } from '../../../shared/AgentIcon';
import { Severity } from '../../../../../common/anomaly_detection';
import { HealthBadge } from './HealthBadge';
import { ServiceListMetric } from './ServiceListMetric';
interface Props {
items: ServiceListAPIResponse['items'];
noItemsMessage?: React.ReactNode;
displayHealthStatus: boolean;
}
type ServiceListItem = ValuesType<Props['items']>;
function formatNumber(value: number) {
if (value === 0) {
return '0';
@ -41,7 +51,18 @@ const AppLink = styled(TransactionOverviewLink)`
${truncate('100%')};
`;
export const SERVICE_COLUMNS = [
export const SERVICE_COLUMNS: Array<ITableColumn<ServiceListItem>> = [
{
field: 'severity',
name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', {
defaultMessage: 'Health',
}),
width: px(unit * 6),
sortable: true,
render: (_, { severity }) => {
return <HealthBadge severity={severity} />;
},
},
{
field: 'serviceName',
name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', {
@ -49,9 +70,24 @@ export const SERVICE_COLUMNS = [
}),
width: '40%',
sortable: true,
render: (serviceName: string) => (
<EuiToolTip content={formatString(serviceName)} id="service-name-tooltip">
<AppLink serviceName={serviceName}>{formatString(serviceName)}</AppLink>
render: (_, { serviceName, agentName }) => (
<EuiToolTip
delay="long"
content={formatString(serviceName)}
id="service-name-tooltip"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
{agentName && (
<EuiFlexItem grow={false}>
<AgentIcon agentName={agentName} />
</EuiFlexItem>
)}
<EuiFlexItem>
<AppLink serviceName={serviceName}>
{formatString(serviceName)}
</AppLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiToolTip>
),
},
@ -60,20 +96,12 @@ export const SERVICE_COLUMNS = [
name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', {
defaultMessage: 'Environment',
}),
width: '20%',
width: px(unit * 10),
sortable: true,
render: (environments: string[]) => (
<EnvironmentBadge environments={environments} />
render: (_, { environments }) => (
<EnvironmentBadge environments={environments ?? []} />
),
},
{
field: 'agentName',
name: i18n.translate('xpack.apm.servicesTable.agentColumnLabel', {
defaultMessage: 'Agent',
}),
sortable: true,
render: (agentName: string) => formatString(agentName),
},
{
field: 'avgResponseTime',
name: i18n.translate('xpack.apm.servicesTable.avgResponseTimeColumnLabel', {
@ -81,7 +109,15 @@ export const SERVICE_COLUMNS = [
}),
sortable: true,
dataType: 'number',
render: (time: number) => asMillisecondDuration(time),
render: (_, { avgResponseTime }) => (
<ServiceListMetric
series={avgResponseTime?.timeseries}
color="euiColorVis1"
valueLabel={asMillisecondDuration(avgResponseTime?.value || 0)}
/>
),
align: 'left',
width: px(unit * 10),
},
{
field: 'transactionsPerMinute',
@ -93,39 +129,107 @@ export const SERVICE_COLUMNS = [
),
sortable: true,
dataType: 'number',
render: (value: number) =>
`${formatNumber(value)} ${i18n.translate(
'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel',
{
defaultMessage: 'tpm',
}
)}`,
render: (_, { transactionsPerMinute }) => (
<ServiceListMetric
series={transactionsPerMinute?.timeseries}
color="euiColorVis0"
valueLabel={`${formatNumber(
transactionsPerMinute?.value || 0
)} ${i18n.translate(
'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel',
{
defaultMessage: 'tpm',
}
)}`}
/>
),
align: 'left',
width: px(unit * 10),
},
{
field: 'errorsPerMinute',
name: i18n.translate('xpack.apm.servicesTable.errorsPerMinuteColumnLabel', {
defaultMessage: 'Errors per minute',
name: i18n.translate('xpack.apm.servicesTable.transactionErrorRate', {
defaultMessage: 'Error rate %',
}),
sortable: true,
dataType: 'number',
render: (value: number) =>
`${formatNumber(value)} ${i18n.translate(
'xpack.apm.servicesTable.errorsPerMinuteUnitLabel',
{
defaultMessage: 'err.',
}
)}`,
render: (_, { transactionErrorRate }) => {
const value = transactionErrorRate?.value;
const valueLabel =
value !== null && value !== undefined ? asPercent(value, 1) : '';
return (
<ServiceListMetric
series={transactionErrorRate?.timeseries}
color="euiColorVis7"
valueLabel={valueLabel}
/>
);
},
align: 'left',
width: px(unit * 10),
},
];
export function ServiceList({ items, noItemsMessage }: Props) {
const SEVERITY_ORDER = [
Severity.warning,
Severity.minor,
Severity.major,
Severity.critical,
];
export function ServiceList({
items,
displayHealthStatus,
noItemsMessage,
}: Props) {
const columns = displayHealthStatus
? SERVICE_COLUMNS
: SERVICE_COLUMNS.filter((column) => column.field !== 'severity');
return (
<ManagedTable
columns={SERVICE_COLUMNS}
columns={columns}
items={items}
noItemsMessage={noItemsMessage}
initialSortField="serviceName"
initialSortField="severity"
initialSortDirection="desc"
initialPageSize={50}
sortFn={(itemsToSort, sortField, sortDirection) => {
// For severity, sort items by severity first, then by TPM
return sortField === 'severity'
? orderBy(
itemsToSort,
[
(item) => {
return item.severity
? SEVERITY_ORDER.indexOf(item.severity)
: -1;
},
(item) => item.transactionsPerMinute?.value ?? 0,
],
[sortDirection, sortDirection]
)
: orderBy(
itemsToSort,
(item) => {
switch (sortField) {
case 'avgResponseTime':
return item.avgResponseTime?.value ?? 0;
case 'transactionsPerMinute':
return item.transactionsPerMinute?.value ?? 0;
case 'transactionErrorRate':
return item.transactionErrorRate?.value ?? 0;
default:
return item[sortField as keyof typeof item];
}
},
sortDirection
);
}}
/>
);
}

View file

@ -8,6 +8,7 @@ import { render, wait, waitForElement } from '@testing-library/react';
import { CoreStart } from 'kibana/public';
import React, { FunctionComponent, ReactChild } from 'react';
import { createKibanaReactContext } from 'src/plugins/kibana_react/public';
import { merge } from 'lodash';
import { ServiceOverview } from '..';
import { ApmPluginContextValue } from '../../../../context/ApmPluginContext';
import {
@ -17,35 +18,38 @@ import {
import { FETCH_STATUS } from '../../../../hooks/useFetcher';
import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters';
import * as urlParamsHooks from '../../../../hooks/useUrlParams';
import * as useAnomalyDetectionJobs from '../../../../hooks/useAnomalyDetectionJobs';
import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock';
import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components';
const KibanaReactContext = createKibanaReactContext({
usageCollection: { reportUiStats: () => {} },
} as Partial<CoreStart>);
const addWarning = jest.fn();
const httpGet = jest.fn();
function wrapper({ children }: { children: ReactChild }) {
const mockPluginContext = (merge({}, mockApmPluginContextValue, {
core: {
http: {
get: httpGet,
},
notifications: {
toasts: {
addWarning,
},
},
},
}) as unknown) as ApmPluginContextValue;
return (
<KibanaReactContext.Provider>
<MockApmPluginContextWrapper
value={
({
...mockApmPluginContextValue,
core: {
...mockApmPluginContextValue.core,
http: { ...mockApmPluginContextValue.core.http, get: httpGet },
notifications: {
...mockApmPluginContextValue.core.notifications,
toasts: {
...mockApmPluginContextValue.core.notifications.toasts,
addWarning,
},
},
},
} as unknown) as ApmPluginContextValue
}
>
{children}
</MockApmPluginContextWrapper>
<EuiThemeProvider>
<MockApmPluginContextWrapper value={mockPluginContext as any}>
{children}
</MockApmPluginContextWrapper>
</EuiThemeProvider>
</KibanaReactContext.Provider>
);
}
@ -56,9 +60,6 @@ function renderServiceOverview() {
});
}
const addWarning = jest.fn();
const httpGet = jest.fn();
describe('Service Overview -> View', () => {
beforeEach(() => {
// @ts-expect-error
@ -80,6 +81,17 @@ describe('Service Overview -> View', () => {
clearValues: () => null,
status: FETCH_STATUS.SUCCESS,
});
jest
.spyOn(useAnomalyDetectionJobs, 'useAnomalyDetectionJobs')
.mockReturnValue({
status: FETCH_STATUS.SUCCESS,
data: {
jobs: [],
hasLegacyJobs: false,
},
refetch: () => undefined,
});
});
afterEach(() => {
@ -99,6 +111,7 @@ describe('Service Overview -> View', () => {
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
severity: 1,
},
{
serviceName: 'My Go Service',
@ -107,6 +120,7 @@ describe('Service Overview -> View', () => {
errorsPerMinute: 500,
avgResponseTime: 600,
environments: [],
severity: 10,
},
],
});
@ -195,4 +209,57 @@ describe('Service Overview -> View', () => {
expect(addWarning).not.toHaveBeenCalled();
});
});
describe('when ML data is not found', () => {
it('does not render the health column', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
},
],
});
const { queryByText } = renderServiceOverview();
// wait for requests to be made
await wait(() => expect(httpGet).toHaveBeenCalledTimes(1));
expect(queryByText('Health')).toBeNull();
});
});
describe('when ML data is found', () => {
it('renders the health column', async () => {
httpGet.mockResolvedValueOnce({
hasLegacyData: false,
hasHistoricalData: true,
items: [
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300,
environments: ['test', 'dev'],
severity: 1,
},
],
});
const { queryAllByText } = renderServiceOverview();
// wait for requests to be made
await wait(() => expect(httpGet).toHaveBeenCalledTimes(1));
expect(queryAllByText('Health').length).toBeGreaterThan(1);
});
});
});

View file

@ -7,7 +7,7 @@ NodeList [
>
<td
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colspan="6"
colspan="5"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
@ -45,7 +45,7 @@ NodeList [
>
<td
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colspan="6"
colspan="5"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
@ -141,110 +141,33 @@ NodeList [
>
<td
class="euiTableRowCell"
style="width: 40%;"
style="width: 96px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Name
Health
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<span
class="euiToolTipAnchor"
class="euiBadge euiBadge--iconLeft"
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
title="Unknown"
>
<a
class="euiLink euiLink--primary c0"
href="/basepath/app/apm/services/My Go Service/transactions"
rel="noreferrer"
<span
class="euiBadge__content"
>
My Go Service
</a>
<span
class="euiBadge__text"
>
Unknown
</span>
</span>
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 20%;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Environment
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
/>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Agent
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
go
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Avg. response time
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
0.6 ms
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Trans. per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
400.0 tpm
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Errors per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
500.0 err.
</div>
</td>
</tr>,
.c0 {
font-size: 16px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 40%;"
@ -260,19 +183,36 @@ NodeList [
<span
class="euiToolTipAnchor"
>
<a
class="euiLink euiLink--primary c0"
href="/basepath/app/apm/services/My Python Service/transactions"
rel="noreferrer"
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
My Python Service
</a>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<img
alt="python"
height="24px"
src="test-file-stub"
/>
</div>
<div
class="euiFlexItem"
>
<a
class="euiLink euiLink--primary c0"
href="/basepath/app/apm/services/My Python Service/transactions"
rel="noreferrer"
>
My Python Service
</a>
</div>
</div>
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 20%;"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
@ -314,20 +254,7 @@ NodeList [
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Agent
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
python
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
@ -335,13 +262,52 @@ NodeList [
Avg. response time
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
0.3 ms
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
>
0 ms
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
@ -349,23 +315,351 @@ NodeList [
Trans. per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
100.0 tpm
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
>
0 tpm
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Errors per minute
Error rate %
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
200.0 err.
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
/>
</div>
</div>
</td>
</tr>,
.c0 {
font-size: 16px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 96px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Health
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<span
class="euiBadge euiBadge--iconLeft"
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
title="Unknown"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
Unknown
</span>
</span>
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 40%;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Name
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<span
class="euiToolTipAnchor"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<img
alt="go"
height="24px"
src="test-file-stub"
/>
</div>
<div
class="euiFlexItem"
>
<a
class="euiLink euiLink--primary c0"
href="/basepath/app/apm/services/My Go Service/transactions"
rel="noreferrer"
>
My Go Service
</a>
</div>
</div>
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Environment
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
/>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Avg. response time
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
>
0 ms
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Trans. per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
>
0 tpm
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell"
style="width: 160px;"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Error rate %
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
color="subdued"
data-euiicon-type="visLine"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small"
>
<div
class="euiTextColor euiTextColor--subdued"
>
N/A
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
style="white-space: nowrap;"
/>
</div>
</div>
</td>
</tr>,

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import React, { useEffect, useMemo } from 'react';
import url from 'url';
import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public';
import { useFetcher } from '../../../hooks/useFetcher';
import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher';
import { NoServicesMessage } from './NoServicesMessage';
import { ServiceList } from './ServiceList';
import { useUrlParams } from '../../../hooks/useUrlParams';
@ -18,8 +18,11 @@ import { useTrackPageview } from '../../../../../observability/public';
import { Projection } from '../../../../common/projections';
import { LocalUIFilters } from '../../shared/LocalUIFilters';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { MLCallout } from './ServiceList/MLCallout';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { useAnomalyDetectionJobs } from '../../../hooks/useAnomalyDetectionJobs';
const initalData = {
const initialData = {
items: [],
hasHistoricalData: true,
hasLegacyData: false,
@ -33,7 +36,7 @@ export function ServiceOverview() {
urlParams: { start, end },
uiFilters,
} = useUrlParams();
const { data = initalData, status } = useFetcher(
const { data = initialData, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi({
@ -93,6 +96,26 @@ export function ServiceOverview() {
[]
);
const {
data: anomalyDetectionJobsData,
status: anomalyDetectionJobsStatus,
} = useAnomalyDetectionJobs();
const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage(
'apm.userHasDismissedServiceInventoryMlCallout',
false
);
const canCreateJob = !!core.application.capabilities.ml?.canCreateJob;
const displayMlCallout =
anomalyDetectionJobsStatus === FETCH_STATUS.SUCCESS &&
!anomalyDetectionJobsData?.jobs.length &&
canCreateJob &&
!userHasDismissedCallout;
const displayHealthStatus = data.items.some((item) => 'severity' in item);
return (
<>
<EuiSpacer />
@ -101,17 +124,27 @@ export function ServiceOverview() {
<LocalUIFilters {...localFiltersConfig} />
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiPanel>
<ServiceList
items={data.items}
noItemsMessage={
<NoServicesMessage
historicalDataFound={data.hasHistoricalData}
status={status}
<EuiFlexGroup direction="column">
{displayMlCallout ? (
<EuiFlexItem>
<MLCallout onDismiss={() => setUserHasDismissedCallout(true)} />
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EuiPanel>
<ServiceList
items={data.items}
displayHealthStatus={displayHealthStatus}
noItemsMessage={
<NoServicesMessage
historicalDataFound={data.hasHistoricalData}
status={status}
/>
}
/>
}
/>
</EuiPanel>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</>

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getNormalizedAgentName } from '../../../../common/agent_name';
import dotNetIcon from './icons/dot-net.svg';
import goIcon from './icons/go.svg';
import javaIcon from './icons/java.svg';
import nodeJsIcon from './icons/nodejs.svg';
import phpIcon from './icons/php.svg';
import pythonIcon from './icons/python.svg';
import rubyIcon from './icons/ruby.svg';
import rumJsIcon from './icons/rumjs.svg';
const agentIcons: { [key: string]: string } = {
dotnet: dotNetIcon,
go: goIcon,
java: javaIcon,
'js-base': rumJsIcon,
nodejs: nodeJsIcon,
php: phpIcon,
python: pythonIcon,
ruby: rubyIcon,
};
export function getAgentIcon(agentName?: string) {
const normalizedAgentName = getNormalizedAgentName(agentName);
return normalizedAgentName && agentIcons[normalizedAgentName];
}

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import { getAgentIcon } from './get_agent_icon';
import { px } from '../../../style/variables';
interface Props {
agentName: AgentName;
}
export function AgentIcon(props: Props) {
const { agentName } = props;
const icon = getAgentIcon(agentName);
return <img src={icon} height={px(24)} alt={agentName} />;
}

View file

@ -33,9 +33,22 @@ interface Props<T> {
hidePerPageOptions?: boolean;
noItemsMessage?: React.ReactNode;
sortItems?: boolean;
sortFn?: (
items: T[],
sortField: string,
sortDirection: 'asc' | 'desc'
) => T[];
pagination?: boolean;
}
function defaultSortFn<T extends any>(
items: T[],
sortField: string,
sortDirection: 'asc' | 'desc'
) {
return orderBy(items, sortField, sortDirection);
}
function UnoptimizedManagedTable<T>(props: Props<T>) {
const history = useHistory();
const {
@ -48,6 +61,7 @@ function UnoptimizedManagedTable<T>(props: Props<T>) {
hidePerPageOptions = true,
noItemsMessage,
sortItems = true,
sortFn = defaultSortFn,
pagination = true,
} = props;
@ -62,11 +76,11 @@ function UnoptimizedManagedTable<T>(props: Props<T>) {
const renderedItems = useMemo(() => {
const sortedItems = sortItems
? orderBy(items, sortField, sortDirection as 'asc' | 'desc')
? sortFn(items, sortField, sortDirection as 'asc' | 'desc')
: items;
return sortedItems.slice(page * pageSize, (page + 1) * pageSize);
}, [page, pageSize, sortField, sortDirection, items, sortItems]);
}, [page, pageSize, sortField, sortDirection, items, sortItems, sortFn]);
const sort = useMemo(() => {
return {

View file

@ -8,9 +8,11 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui';
import { getSeverityColor } from '../../app/ServiceMap/cytoscapeOptions';
import {
getSeverityColor,
Severity,
} from '../../../../common/anomaly_detection';
import { useTheme } from '../../../hooks/useTheme';
import { severity as Severity } from '../../app/ServiceMap/Popover/getSeverity';
type SeverityScore = 0 | 25 | 50 | 75;
const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75];

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ScaleType, Chart, Settings, AreaSeries } from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
import { px } from '../../../../style/variables';
import { useChartTheme } from '../../../../../../observability/public';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
interface Props {
color: string;
series: Array<{ x: number; y: number | null }>;
}
export function SparkPlot(props: Props) {
const { series, color } = props;
const chartTheme = useChartTheme();
const isEmpty = series.every((point) => point.y === null);
if (isEmpty) {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="visLine" color="subdued" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{NOT_AVAILABLE_LABEL}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<Chart size={{ height: px(24), width: px(64) }}>
<Settings
theme={{
...chartTheme,
background: {
...chartTheme.background,
color: 'transparent',
},
}}
showLegend={false}
tooltip="none"
/>
<AreaSeries
id="area"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={series}
color={color}
/>
</Chart>
);
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useFetcher } from './useFetcher';
export function useAnomalyDetectionJobs() {
return useFetcher(
(callApmApi) =>
callApmApi({
pathname: `/api/apm/settings/anomaly-detection`,
}),
[],
{ showToastOnError: false }
);
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, defaultValue: T) {
const [item, setItem] = useState<T>(getFromStorage());
function getFromStorage() {
const storedItem = window.localStorage.getItem(key);
let toStore: T = defaultValue;
if (storedItem !== null) {
try {
toStore = JSON.parse(storedItem) as T;
} catch (err) {
window.localStorage.removeItem(key);
// eslint-disable-next-line no-console
console.log(`Unable to decode: ${key}`);
}
}
return toStore;
}
const updateFromStorage = () => {
const storedItem = getFromStorage();
setItem(storedItem);
};
const saveToStorage = (value: T) => {
if (value === undefined) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, JSON.stringify(value));
updateFromStorage();
}
};
useEffect(() => {
window.addEventListener('storage', (event: StorageEvent) => {
if (event.key === key) {
updateFromStorage();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [item, saveToStorage] as const;
}

View file

@ -1,7 +1,8 @@
{
"extends": "../../../../tsconfig.base.json",
"include": [
"./**/*"
"./**/*",
"../observability"
],
"exclude": [],
"compilerOptions": {

View file

@ -81,6 +81,11 @@ export function registerTransactionDurationAnomalyAlertType({
anomalyDetectors,
alertParams.environment
);
if (mlJobIds.length === 0) {
return {};
}
const anomalySearchParams = {
body: {
size: 0,

View file

@ -7,22 +7,23 @@
import moment from 'moment';
// @ts-expect-error
import { calculateAuto } from './calculate_auto';
// @ts-expect-error
import { unitToSeconds } from './unit_to_seconds';
export function getBucketSize(start: number, end: number, interval: string) {
export function getBucketSize(
start: number,
end: number,
numBuckets: number = 100
) {
const duration = moment.duration(end - start, 'ms');
const bucketSize = Math.max(calculateAuto.near(100, duration).asSeconds(), 1);
const bucketSize = Math.max(
calculateAuto.near(numBuckets, duration).asSeconds(),
1
);
const intervalString = `${bucketSize}s`;
const matches = interval && interval.match(/^([\d]+)([shmdwMy]|ms)$/);
const minBucketSize = matches
? Number(matches[1]) * unitToSeconds(matches[2])
: 0;
if (bucketSize < minBucketSize) {
if (bucketSize < 0) {
return {
bucketSize: minBucketSize,
intervalString: interval,
bucketSize: 0,
intervalString: 'auto',
};
}

View file

@ -11,7 +11,7 @@ export function getMetricsDateHistogramParams(
end: number,
metricsInterval: number
) {
const { bucketSize } = getBucketSize(start, end, 'auto');
const { bucketSize } = getBucketSize(start, end);
return {
field: '@timestamp',

View file

@ -5,6 +5,7 @@
*/
import moment from 'moment';
import { isActivePlatinumLicense } from '../../../common/service_map';
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
import { KibanaRequest } from '../../../../../../src/core/server';
import { APMConfig } from '../..';
@ -98,11 +99,14 @@ export async function setupRequest<TParams extends SetupRequestParams>(
context,
request,
}),
ml: getMlSetup(
context.plugins.ml,
context.core.savedObjects.client,
request
),
ml:
context.plugins.ml && isActivePlatinumLicense(context.licensing.license)
? getMlSetup(
context.plugins.ml,
context.core.savedObjects.client,
request
)
: undefined,
config,
};
@ -115,14 +119,10 @@ export async function setupRequest<TParams extends SetupRequestParams>(
}
function getMlSetup(
ml: APMRequestHandlerContext['plugins']['ml'],
ml: Required<APMRequestHandlerContext['plugins']>['ml'],
savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'],
request: KibanaRequest
) {
if (!ml) {
return;
}
return {
mlSystem: ml.mlSystemProvider(request),
anomalyDetectors: ml.anomalyDetectorsProvider(request),

View file

@ -44,7 +44,7 @@ export async function fetchAndTransformGcMetrics({
}) {
const { start, end, apmEventClient, config } = setup;
const { bucketSize } = getBucketSize(start, end, 'auto');
const { bucketSize } = getBucketSize(start, end);
const projection = getMetricsProjection({
setup,
@ -74,7 +74,7 @@ export async function fetchAndTransformGcMetrics({
field: `${LABEL_NAME}`,
},
aggs: {
over_time: {
timeseries: {
date_histogram: getMetricsDateHistogramParams(
start,
end,
@ -123,7 +123,7 @@ export async function fetchAndTransformGcMetrics({
const series = aggregations.per_pool.buckets.map((poolBucket, i) => {
const label = poolBucket.key as string;
const timeseriesData = poolBucket.over_time;
const timeseriesData = poolBucket.timeseries;
const data = timeseriesData.buckets.map((bucket) => {
// derivative/value will be undefined for the first hit and if the `max` value is null

View file

@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from 'kibana/server';
import Boom from 'boom';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
import { PromiseReturnType } from '../../../typings/common';
@ -27,11 +26,9 @@ export type ServiceAnomaliesResponse = PromiseReturnType<
export async function getServiceAnomalies({
setup,
logger,
environment,
}: {
setup: Setup & SetupTimeRange;
logger: Logger;
environment?: string;
}) {
const { ml, start, end } = setup;
@ -41,11 +38,20 @@ export async function getServiceAnomalies({
}
const mlCapabilities = await ml.mlSystem.mlCapabilities();
if (!mlCapabilities.mlFeatureEnabledInSpace) {
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
}
const mlJobIds = await getMLJobIds(ml.anomalyDetectors, environment);
if (!mlJobIds.length) {
return {
mlJobIds: [],
serviceAnomalies: {},
};
}
const params = {
body: {
size: 0,
@ -120,7 +126,9 @@ interface ServiceAnomaliesAggResponse {
function transformResponseToServiceAnomalies(
response: ServiceAnomaliesAggResponse
): Record<string, ServiceAnomalyStats> {
const serviceAnomaliesMap = response.aggregations.services.buckets.reduce(
const serviceAnomaliesMap = (
response.aggregations?.services.buckets ?? []
).reduce(
(statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => {
return {
...statsByServiceName,
@ -153,7 +161,7 @@ export async function getMLJobIds(
(job) => job.custom_settings?.job_tags?.environment === environment
);
if (!matchingMLJob) {
throw new Error(`ML job Not Found for environment "${environment}".`);
return [];
}
return [matchingMLJob.job_id];
}

View file

@ -58,6 +58,9 @@ describe('getServiceMapServiceNodeInfo', () => {
indices: {},
start: 1593460053026000,
end: 1593497863217000,
config: {
'xpack.apm.metricsInterval': 30,
},
} as unknown) as Setup & SetupTimeRange;
const environment = 'test environment';
const serviceName = 'test service name';

View file

@ -105,6 +105,24 @@ Array [
"field": "transaction.duration.us",
},
},
"timeseries": Object {
"aggs": Object {
"average": Object {
"avg": Object {
"field": "transaction.duration.us",
},
},
},
"date_histogram": Object {
"extended_bounds": Object {
"max": 1528977600000,
"min": 1528113600000,
},
"field": "@timestamp",
"fixed_interval": "43200s",
"min_doc_count": 0,
},
},
},
"terms": Object {
"field": "service.name",
@ -194,6 +212,19 @@ Array [
"body": Object {
"aggs": Object {
"services": Object {
"aggs": Object {
"timeseries": Object {
"date_histogram": Object {
"extended_bounds": Object {
"max": 1528977600000,
"min": 1528113600000,
},
"field": "@timestamp",
"fixed_interval": "43200s",
"min_doc_count": 0,
},
},
},
"terms": Object {
"field": "service.name",
"size": 500,
@ -226,12 +257,37 @@ Array [
Object {
"apm": Object {
"events": Array [
"error",
"transaction",
],
},
"body": Object {
"aggs": Object {
"services": Object {
"aggs": Object {
"outcomes": Object {
"terms": Object {
"field": "event.outcome",
},
},
"timeseries": Object {
"aggs": Object {
"outcomes": Object {
"terms": Object {
"field": "event.outcome",
},
},
},
"date_histogram": Object {
"extended_bounds": Object {
"max": 1528977600000,
"min": 1528113600000,
},
"field": "@timestamp",
"fixed_interval": "43200s",
"min_doc_count": 0,
},
},
},
"terms": Object {
"field": "service.name",
"size": 500,
@ -255,6 +311,14 @@ Array [
"my.custom.ui.filter": "foo-bar",
},
},
Object {
"terms": Object {
"event.outcome": Array [
"failure",
"success",
],
},
},
],
},
},

View file

@ -15,15 +15,22 @@ import {
getTransactionDurationAverages,
getAgentNames,
getTransactionRates,
getErrorRates,
getTransactionErrorRates,
getEnvironments,
getHealthStatuses,
} from './get_services_items_stats';
export type ServiceListAPIResponse = PromiseReturnType<typeof getServicesItems>;
export type ServicesItemsSetup = Setup & SetupTimeRange & SetupUIFilters;
export type ServicesItemsProjection = ReturnType<typeof getServicesProjection>;
export async function getServicesItems(setup: ServicesItemsSetup) {
export async function getServicesItems({
setup,
mlAnomaliesEnvironment,
}: {
setup: ServicesItemsSetup;
mlAnomaliesEnvironment?: string;
}) {
const params = {
projection: getServicesProjection({ setup }),
setup,
@ -33,22 +40,25 @@ export async function getServicesItems(setup: ServicesItemsSetup) {
transactionDurationAverages,
agentNames,
transactionRates,
errorRates,
transactionErrorRates,
environments,
healthStatuses,
] = await Promise.all([
getTransactionDurationAverages(params),
getAgentNames(params),
getTransactionRates(params),
getErrorRates(params),
getTransactionErrorRates(params),
getEnvironments(params),
getHealthStatuses(params, mlAnomaliesEnvironment),
]);
const allMetrics = [
...transactionDurationAverages,
...agentNames,
...transactionRates,
...errorRates,
...transactionErrorRates,
...environments,
...healthStatuses,
];
return joinByKey(allMetrics, 'serviceName');

View file

@ -4,10 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EventOutcome } from '../../../../common/event_outcome';
import { getSeverity } from '../../../../common/anomaly_detection';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import {
TRANSACTION_DURATION,
AGENT_NAME,
SERVICE_ENVIRONMENT,
EVENT_OUTCOME,
} from '../../../../common/elasticsearch_fieldnames';
import { mergeProjection } from '../../../projections/util/merge_projection';
import { ProcessorEvent } from '../../../../common/processor_event';
@ -15,6 +19,21 @@ import {
ServicesItemsSetup,
ServicesItemsProjection,
} from './get_services_items';
import { getBucketSize } from '../../helpers/get_bucket_size';
import {
getMLJobIds,
getServiceAnomalies,
} from '../../service_map/get_service_anomalies';
import { AggregationResultOf } from '../../../../typings/elasticsearch/aggregations';
function getDateHistogramOpts(start: number, end: number) {
return {
field: '@timestamp',
fixed_interval: getBucketSize(start, end, 20).intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
};
}
const MAX_NUMBER_OF_SERVICES = 500;
@ -30,7 +49,7 @@ export const getTransactionDurationAverages = async ({
setup,
projection,
}: AggregationParams) => {
const { apmEventClient } = setup;
const { apmEventClient, start, end } = setup;
const response = await apmEventClient.search(
mergeProjection(projection, {
@ -51,6 +70,16 @@ export const getTransactionDurationAverages = async ({
field: TRANSACTION_DURATION,
},
},
timeseries: {
date_histogram: getDateHistogramOpts(start, end),
aggs: {
average: {
avg: {
field: TRANSACTION_DURATION,
},
},
},
},
},
},
},
@ -64,9 +93,15 @@ export const getTransactionDurationAverages = async ({
return [];
}
return aggregations.services.buckets.map((bucket) => ({
serviceName: bucket.key as string,
avgResponseTime: bucket.average.value,
return aggregations.services.buckets.map((serviceBucket) => ({
serviceName: serviceBucket.key as string,
avgResponseTime: {
value: serviceBucket.average.value,
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.average.value,
})),
},
}));
};
@ -112,9 +147,10 @@ export const getAgentNames = async ({
return [];
}
return aggregations.services.buckets.map((bucket) => ({
serviceName: bucket.key as string,
agentName: bucket.agent_name.hits.hits[0]?._source.agent.name,
return aggregations.services.buckets.map((serviceBucket) => ({
serviceName: serviceBucket.key as string,
agentName: serviceBucket.agent_name.hits.hits[0]?._source.agent
.name as AgentName,
}));
};
@ -122,7 +158,7 @@ export const getTransactionRates = async ({
setup,
projection,
}: AggregationParams) => {
const { apmEventClient } = setup;
const { apmEventClient, start, end } = setup;
const response = await apmEventClient.search(
mergeProjection(projection, {
apm: {
@ -136,46 +172,10 @@ export const getTransactionRates = async ({
...projection.body.aggs.services.terms,
size: MAX_NUMBER_OF_SERVICES,
},
},
},
},
})
);
const { aggregations } = response;
if (!aggregations) {
return [];
}
const deltaAsMinutes = getDeltaAsMinutes(setup);
return aggregations.services.buckets.map((bucket) => {
const transactionsPerMinute = bucket.doc_count / deltaAsMinutes;
return {
serviceName: bucket.key as string,
transactionsPerMinute,
};
});
};
export const getErrorRates = async ({
setup,
projection,
}: AggregationParams) => {
const { apmEventClient } = setup;
const response = await apmEventClient.search(
mergeProjection(projection, {
apm: {
events: [ProcessorEvent.error],
},
body: {
size: 0,
aggs: {
services: {
terms: {
...projection.body.aggs.services.terms,
size: MAX_NUMBER_OF_SERVICES,
aggs: {
timeseries: {
date_histogram: getDateHistogramOpts(start, end),
},
},
},
},
@ -191,11 +191,109 @@ export const getErrorRates = async ({
const deltaAsMinutes = getDeltaAsMinutes(setup);
return aggregations.services.buckets.map((bucket) => {
const errorsPerMinute = bucket.doc_count / deltaAsMinutes;
return aggregations.services.buckets.map((serviceBucket) => {
const transactionsPerMinute = serviceBucket.doc_count / deltaAsMinutes;
return {
serviceName: bucket.key as string,
errorsPerMinute,
serviceName: serviceBucket.key as string,
transactionsPerMinute: {
value: transactionsPerMinute,
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.doc_count / deltaAsMinutes,
})),
},
};
});
};
export const getTransactionErrorRates = async ({
setup,
projection,
}: AggregationParams) => {
const { apmEventClient, start, end } = setup;
const outcomes = {
terms: {
field: EVENT_OUTCOME,
},
};
const response = await apmEventClient.search(
mergeProjection(projection, {
apm: {
events: [ProcessorEvent.transaction],
},
body: {
size: 0,
query: {
bool: {
filter: [
...projection.body.query.bool.filter,
{
terms: {
[EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success],
},
},
],
},
},
aggs: {
services: {
terms: {
...projection.body.aggs.services.terms,
size: MAX_NUMBER_OF_SERVICES,
},
aggs: {
outcomes,
timeseries: {
date_histogram: getDateHistogramOpts(start, end),
aggs: {
outcomes,
},
},
},
},
},
},
})
);
const { aggregations } = response;
if (!aggregations) {
return [];
}
function calculateTransactionErrorPercentage(
outcomeResponse: AggregationResultOf<typeof outcomes, {}>
) {
const successfulTransactions =
outcomeResponse.buckets.find(
(bucket) => bucket.key === EventOutcome.success
)?.doc_count ?? 0;
const failedTransactions =
outcomeResponse.buckets.find(
(bucket) => bucket.key === EventOutcome.failure
)?.doc_count ?? 0;
return failedTransactions / (successfulTransactions + failedTransactions);
}
return aggregations.services.buckets.map((serviceBucket) => {
const transactionErrorRate = calculateTransactionErrorPercentage(
serviceBucket.outcomes
);
return {
serviceName: serviceBucket.key as string,
transactionErrorRate: {
value: transactionErrorRate,
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => {
return {
x: dateBucket.key,
y: calculateTransactionErrorPercentage(dateBucket.outcomes),
};
}),
},
};
});
};
@ -241,8 +339,43 @@ export const getEnvironments = async ({
return [];
}
return aggregations.services.buckets.map((bucket) => ({
serviceName: bucket.key as string,
environments: bucket.environments.buckets.map((env) => env.key as string),
return aggregations.services.buckets.map((serviceBucket) => ({
serviceName: serviceBucket.key as string,
environments: serviceBucket.environments.buckets.map(
(envBucket) => envBucket.key as string
),
}));
};
export const getHealthStatuses = async (
{ setup }: AggregationParams,
mlAnomaliesEnvironment?: string
) => {
if (!setup.ml) {
return [];
}
const jobIds = await getMLJobIds(
setup.ml.anomalyDetectors,
mlAnomaliesEnvironment
);
if (!jobIds.length) {
return [];
}
const anomalies = await getServiceAnomalies({
setup,
environment: mlAnomaliesEnvironment,
});
return Object.keys(anomalies.serviceAnomalies).map((serviceName) => {
const stats = anomalies.serviceAnomalies[serviceName];
const severity = getSeverity(stats.anomalyScore);
return {
serviceName,
severity,
};
});
};

View file

@ -17,11 +17,15 @@ import { getServicesItems } from './get_services_items';
export type ServiceListAPIResponse = PromiseReturnType<typeof getServices>;
export async function getServices(
setup: Setup & SetupTimeRange & SetupUIFilters
) {
export async function getServices({
setup,
mlAnomaliesEnvironment,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
mlAnomaliesEnvironment?: string;
}) {
const [items, hasLegacyData] = await Promise.all([
getServicesItems(setup),
getServicesItems({ setup, mlAnomaliesEnvironment }),
getLegacyDataStatus(setup),
]);

View file

@ -38,7 +38,7 @@ describe('services queries', () => {
});
it('fetches the service items', async () => {
mock = await inspectSearchParams((setup) => getServicesItems(setup));
mock = await inspectSearchParams((setup) => getServicesItems({ setup }));
const allParams = mock.spy.mock.calls.map((call) => call[0]);

View file

@ -62,7 +62,7 @@ export async function getErrorRate({
total_transactions: {
date_histogram: {
field: '@timestamp',
fixed_interval: getBucketSize(start, end, 'auto').intervalString,
fixed_interval: getBucketSize(start, end).intervalString,
min_doc_count: 0,
extended_bounds: { min: start, max: end },
},

View file

@ -24,7 +24,7 @@ export type ESResponse = PromiseReturnType<typeof fetcher>;
export function fetcher(options: Options) {
const { end, apmEventClient, start, uiFiltersES } = options.setup;
const { serviceName, transactionName } = options;
const { intervalString } = getBucketSize(start, end, 'auto');
const { intervalString } = getBucketSize(start, end);
const transactionNameFilter = transactionName
? [{ term: { [TRANSACTION_NAME]: transactionName } }]

View file

@ -64,16 +64,10 @@ export async function getAnomalySeries({
return;
}
let mlJobIds: string[] = [];
try {
mlJobIds = await getMLJobIds(
setup.ml.anomalyDetectors,
uiFilters.environment
);
} catch (error) {
logger.error(error);
return;
}
const mlJobIds = await getMLJobIds(
setup.ml.anomalyDetectors,
uiFilters.environment
);
// don't fetch anomalies if there are isn't exaclty 1 ML job match for the given environment
if (mlJobIds.length !== 1) {
@ -87,7 +81,7 @@ export async function getAnomalySeries({
}
const { start, end } = setup;
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');
const { intervalString, bucketSize } = getBucketSize(start, end);
const esResponse = await anomalySeriesFetcher({
serviceName,

View file

@ -35,7 +35,7 @@ export function timeseriesFetcher({
setup: Setup & SetupTimeRange & SetupUIFilters;
}) {
const { start, end, uiFiltersES, apmEventClient } = setup;
const { intervalString } = getBucketSize(start, end, 'auto');
const { intervalString } = getBucketSize(start, end);
const filter: ESFilter[] = [
{ term: { [SERVICE_NAME]: serviceName } },

View file

@ -20,7 +20,7 @@ export async function getApmTimeseriesData(options: {
setup: Setup & SetupTimeRange & SetupUIFilters;
}) {
const { start, end } = options.setup;
const { bucketSize } = getBucketSize(start, end, 'auto');
const { bucketSize } = getBucketSize(start, end);
const durationAsMinutes = (end - start) / 1000 / 60;
const timeseriesResponse = await timeseriesFetcher(options);

View file

@ -8,7 +8,7 @@ import Boom from 'boom';
import * as t from 'io-ts';
import {
invalidLicenseMessage,
isValidPlatinumLicense,
isActivePlatinumLicense,
} from '../../common/service_map';
import { setupRequest } from '../lib/helpers/setup_request';
import { getServiceMap } from '../lib/service_map/get_service_map';
@ -33,7 +33,7 @@ export const serviceMapRoute = createRoute(() => ({
if (!context.config['xpack.apm.serviceMapEnabled']) {
throw Boom.notFound();
}
if (!isValidPlatinumLicense(context.licensing.license)) {
if (!isActivePlatinumLicense(context.licensing.license)) {
throw Boom.forbidden(invalidLicenseMessage);
}
context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME);
@ -59,7 +59,7 @@ export const serviceMapServiceNodeRoute = createRoute(() => ({
if (!context.config['xpack.apm.serviceMapEnabled']) {
throw Boom.notFound();
}
if (!isValidPlatinumLicense(context.licensing.license)) {
if (!isActivePlatinumLicense(context.licensing.license)) {
throw Boom.forbidden(invalidLicenseMessage);
}
const logger = context.logger;

View file

@ -16,6 +16,7 @@ import { createRoute } from './create_route';
import { uiFiltersRt, rangeRt } from './default_api_types';
import { getServiceAnnotations } from '../lib/services/annotations';
import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt';
import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters';
export const servicesRoute = createRoute(() => ({
path: '/api/apm/services',
@ -23,8 +24,17 @@ export const servicesRoute = createRoute(() => ({
query: t.intersection([uiFiltersRt, rangeRt]),
},
handler: async ({ context, request }) => {
const { environment } = getParsedUiFilters({
uiFilters: context.params.query.uiFilters,
logger: context.logger,
});
const setup = await setupRequest(context, request);
const services = await getServices(setup);
const services = await getServices({
setup,
mlAnomaliesEnvironment: environment,
});
return services;
},

View file

@ -6,6 +6,7 @@
import * as t from 'io-ts';
import Boom from 'boom';
import { isActivePlatinumLicense } from '../../../common/service_map';
import { ML_ERRORS } from '../../../common/anomaly_detection';
import { createRoute } from '../create_route';
import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs';
@ -24,8 +25,7 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const license = context.licensing.license;
if (!license.isActive || !license.hasAtLeast('platinum')) {
if (!isActivePlatinumLicense(context.licensing.license)) {
throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE);
}
@ -56,8 +56,7 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({
const { environments } = context.params.body;
const setup = await setupRequest(context, request);
const license = context.licensing.license;
if (!license.isActive || !license.hasAtLeast('platinum')) {
if (!isActivePlatinumLicense(context.licensing.license)) {
throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE);
}

View file

@ -346,6 +346,12 @@ export type ValidAggregationKeysOf<
T extends Record<string, any>
> = keyof (UnionToIntersection<T> extends never ? T : UnionToIntersection<T>);
export type AggregationResultOf<
TAggregationOptionsMap extends AggregationOptionsMap,
TDocument
> = AggregationResponsePart<TAggregationOptionsMap, TDocument>[AggregationType &
ValidAggregationKeysOf<TAggregationOptionsMap>];
export type AggregationResponseMap<
TAggregationInputMap extends AggregationInputMap | undefined,
TDocument

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import { useTheme } from './use_theme';
export function useChartTheme() {
const theme = useContext(ThemeContext);
const theme = useTheme();
return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme;
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import { EuiTheme } from '../../../../legacy/common/eui_styled_components';
export function useTheme() {
const theme: EuiTheme = useContext(ThemeContext);
return theme;
}

View file

@ -26,3 +26,6 @@ export {
} from './hooks/use_track_metric';
export * from './typings';
export { useChartTheme } from './hooks/use_chart_theme';
export { useTheme } from './hooks/use_theme';

View file

@ -4863,12 +4863,9 @@
"xpack.apm.serviceOverview.upgradeAssistantLink": "アップグレードアシスタント",
"xpack.apm.servicesTable.7xOldDataMessage": "また、移行が必要な古いデータがある可能性もあります。",
"xpack.apm.servicesTable.7xUpgradeServerMessage": "バージョン7.xより前からのアップグレードですかまた、\n APMサーバーインスタンスを7.0以降にアップグレードしていることも確認してください。",
"xpack.apm.servicesTable.agentColumnLabel": "エージェント",
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均応答時間",
"xpack.apm.servicesTable.environmentColumnLabel": "環境",
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 個の環境} other {# 個の環境}}",
"xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "1 分あたりのエラー",
"xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "エラー",
"xpack.apm.servicesTable.nameColumnLabel": "名前",
"xpack.apm.servicesTable.noServicesLabel": "APM サービスがインストールされていないようです。追加しましょう!",
"xpack.apm.servicesTable.notFoundLabel": "サービスが見つかりません",

View file

@ -4866,12 +4866,9 @@
"xpack.apm.serviceOverview.upgradeAssistantLink": "升级助手",
"xpack.apm.servicesTable.7xOldDataMessage": "可能还有需要迁移的旧数据。",
"xpack.apm.servicesTable.7xUpgradeServerMessage": "从 7.x 之前的版本升级?另外,确保您已将\n APM Server 实例升级到至少 7.0。",
"xpack.apm.servicesTable.agentColumnLabel": "代理",
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均响应时间",
"xpack.apm.servicesTable.environmentColumnLabel": "环境",
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 个环境} other {# 个环境}}",
"xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "每分钟错误数",
"xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "错误",
"xpack.apm.servicesTable.nameColumnLabel": "名称",
"xpack.apm.servicesTable.noServicesLabel": "似乎您没有安装任何 APM 服务。让我们添加一些!",
"xpack.apm.servicesTable.notFoundLabel": "未找到任何服务",

View file

@ -12,7 +12,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const range = archives['apm_8.0.0'];
const archiveName = 'apm_8.0.0';
const range = archives[archiveName];
const start = encodeURIComponent(range.start);
const end = encodeURIComponent(range.end);
@ -29,8 +30,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
describe('when data is loaded', () => {
before(() => esArchiver.load('apm_8.0.0'));
after(() => esArchiver.unload('apm_8.0.0'));
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
it('returns the agent name', async () => {
const response = await supertest.get(

View file

@ -4,18 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { sortBy } from 'lodash';
import expect from '@kbn/expect';
import { isEmpty, pick } from 'lodash';
import { PromiseReturnType } from '../../../../../plugins/apm/typings/common';
import { expectSnapshot } from '../../../common/match_snapshot';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import archives_metadata from '../../../common/archives_metadata';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const range = archives_metadata[archiveName];
// url parameters
const start = encodeURIComponent('2020-06-29T06:45:00.000Z');
const end = encodeURIComponent('2020-06-29T06:49:00.000Z');
const start = encodeURIComponent(range.start);
const end = encodeURIComponent(range.end);
const uiFilters = encodeURIComponent(JSON.stringify({}));
describe('APM Services Overview', () => {
@ -31,52 +38,189 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
describe('when data is loaded', () => {
before(() => esArchiver.load('8.0.0'));
after(() => esArchiver.unload('8.0.0'));
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
it('returns a list of services', async () => {
const response = await supertest.get(
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
);
// sort services to mitigate unstable sort order
const services = sortBy(response.body.items, ['serviceName']);
describe('and fetching a list of services', () => {
let response: PromiseReturnType<typeof supertest.get>;
before(async () => {
response = await supertest.get(
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
);
});
expect(response.status).to.be(200);
expectSnapshot(services).toMatchInline(`
Array [
Object {
"agentName": "rum-js",
"avgResponseTime": 116375,
"environments": Array [],
"errorsPerMinute": 2.75,
"serviceName": "client",
"transactionsPerMinute": 2,
},
Object {
"agentName": "java",
"avgResponseTime": 25636.349593495936,
"environments": Array [
it('the response is successful', () => {
expect(response.status).to.eql(200);
});
it('returns hasHistoricalData: true', () => {
expect(response.body.hasHistoricalData).to.be(true);
});
it('returns hasLegacyData: false', () => {
expect(response.body.hasLegacyData).to.be(false);
});
it('returns the correct service names', () => {
expectSnapshot(response.body.items.map((item: any) => item.serviceName)).toMatchInline(`
Array [
"opbeans-python",
"opbeans-node",
"opbeans-ruby",
"opbeans-go",
"opbeans-dotnet",
"opbeans-java",
"opbeans-rum",
]
`);
});
it('returns the correct metrics averages', () => {
expectSnapshot(
response.body.items.map((item: any) =>
pick(
item,
'transactionErrorRate.value',
'avgResponseTime.value',
'transactionsPerMinute.value'
)
)
).toMatchInline(`
Array [
Object {
"avgResponseTime": Object {
"value": 208079.9121184089,
},
"transactionErrorRate": Object {
"value": 0.041666666666666664,
},
"transactionsPerMinute": Object {
"value": 18.016666666666666,
},
},
Object {
"avgResponseTime": Object {
"value": 578297.1431623931,
},
"transactionErrorRate": Object {
"value": 0.03317535545023697,
},
"transactionsPerMinute": Object {
"value": 7.8,
},
},
Object {
"avgResponseTime": Object {
"value": 60518.587926509186,
},
"transactionErrorRate": Object {
"value": 0.013123359580052493,
},
"transactionsPerMinute": Object {
"value": 6.35,
},
},
Object {
"avgResponseTime": Object {
"value": 25259.78717201166,
},
"transactionErrorRate": Object {
"value": 0.014577259475218658,
},
"transactionsPerMinute": Object {
"value": 5.716666666666667,
},
},
Object {
"avgResponseTime": Object {
"value": 527290.3218390804,
},
"transactionErrorRate": Object {
"value": 0.01532567049808429,
},
"transactionsPerMinute": Object {
"value": 4.35,
},
},
Object {
"avgResponseTime": Object {
"value": 530245.8571428572,
},
"transactionErrorRate": Object {
"value": 0.15384615384615385,
},
"transactionsPerMinute": Object {
"value": 3.033333333333333,
},
},
Object {
"avgResponseTime": Object {
"value": 896134.328358209,
},
"transactionsPerMinute": Object {
"value": 2.2333333333333334,
},
},
]
`);
});
it('returns environments', () => {
expectSnapshot(response.body.items.map((item: any) => item.environments ?? []))
.toMatchInline(`
Array [
Array [
"production",
],
"errorsPerMinute": 4.5,
"serviceName": "opbeans-java",
"transactionsPerMinute": 30.75,
},
Object {
"agentName": "nodejs",
"avgResponseTime": 38682.52419354839,
"environments": Array [
Array [
"testing",
],
Array [
"production",
],
"errorsPerMinute": 3.75,
"serviceName": "opbeans-node",
"transactionsPerMinute": 31,
},
]
`);
Array [
"testing",
],
Array [
"production",
],
Array [
"production",
],
Array [
"testing",
],
]
`);
});
expect(response.body.hasHistoricalData).to.be(true);
expect(response.body.hasLegacyData).to.be(false);
it(`RUM services don't report any transaction error rates`, () => {
// RUM transactions don't have event.outcome set,
// so they should not have an error rate
const rumServices = response.body.items.filter(
(item: any) => item.agentName === 'rum-js'
);
expect(rumServices.length).to.be.greaterThan(0);
expect(rumServices.every((item: any) => isEmpty(item.transactionErrorRate?.value)));
});
it('non-RUM services all report transaction error rates', () => {
const nonRumServices = response.body.items.filter(
(item: any) => item.agentName !== 'rum-js'
);
expect(
nonRumServices.every((item: any) => {
return (
typeof item.transactionErrorRate?.value === 'number' &&
item.transactionErrorRate.timeseries.length > 0
);
})
).to.be(true);
});
});
});
});

View file

@ -16,6 +16,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
describe('Services', function () {
loadTestFile(require.resolve('./services/annotations'));
loadTestFile(require.resolve('./services/rum_services.ts'));
loadTestFile(require.resolve('./services/top_services.ts'));
});
describe('Settings', function () {

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;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { expectSnapshot } from '../../../common/match_snapshot';
import { PromiseReturnType } from '../../../../../plugins/apm/typings/common';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import archives_metadata from '../../../common/archives_metadata';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const range = archives_metadata[archiveName];
// url parameters
const start = encodeURIComponent(range.start);
const end = encodeURIComponent(range.end);
const uiFilters = encodeURIComponent(JSON.stringify({}));
describe('APM Services Overview', () => {
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
describe('and fetching a list of services', () => {
let response: PromiseReturnType<typeof supertest.get>;
before(async () => {
response = await supertest.get(
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
);
});
it('the response is successful', () => {
expect(response.status).to.eql(200);
});
it('there is at least one service', () => {
expect(response.body.items.length).to.be.greaterThan(0);
});
it('some items have severity set', () => {
// Under the assumption that the loaded archive has
// at least one APM ML job, and the time range is longer
// than 15m, at least one items should have severity set.
// Note that we currently have a bug where healthy services
// report as unknown (so without any severity status):
// https://github.com/elastic/kibana/issues/77083
const severityScores = response.body.items.map((item: any) => item.severity);
expect(severityScores.filter(Boolean).length).to.be.greaterThan(0);
expectSnapshot(severityScores).toMatchInline(`
Array [
undefined,
undefined,
undefined,
undefined,
undefined,
"warning",
undefined,
]
`);
});
});
});
});
}