diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts b/x-pack/plugins/apm/common/anomaly_detection.test.ts similarity index 74% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts rename to x-pack/plugins/apm/common/anomaly_detection.test.ts index 52b7d54236db6..21963b5300f83 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.test.ts @@ -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); }); }); }); diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 07270b572a4be..5d80ee6381267 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -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', diff --git a/x-pack/plugins/apm/common/service_map.test.ts b/x-pack/plugins/apm/common/service_map.test.ts index 346403efc46ae..31f439a7aaec9 100644 --- a/x-pack/plugins/apm/common/service_map.test.ts +++ b/x-pack/plugins/apm/common/service_map.test.ts @@ -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); }); }); }); diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 7f46fc685d9ca..1dc4d598cd2ee 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -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'); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index b3d19e1aab2cc..5699d0b56219b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -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; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts deleted file mode 100644 index f4eb2033e9231..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts +++ /dev/null @@ -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; - } -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 9fedcc70bbbcf..1ac7157cc2aad 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -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; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts index 2f4cc0d39d71c..c85cf85d38702 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -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; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg deleted file mode 100644 index 9ae4b31c1a0d6..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index 83fab95bc91c9..cb5a57e9ab9fb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -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) ? (
+ {getSeverityLabel(severity)} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx new file mode 100644 index 0000000000000..dd632db0f15fe --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/MLCallout.tsx @@ -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 ( + +

+ {i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.content', { + defaultMessage: `Our integration with ML anomaly detection will enable you to see your services' health status`, + })} +

+ + + + + {i18n.translate( + 'xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton', + { + defaultMessage: `Learn more`, + } + )} + + + + + onDismiss()}> + {i18n.translate( + 'xpack.apm.serviceOverview.mlNudgeMessage.dismissButton', + { + defaultMessage: `Dismiss message`, + } + )} + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx new file mode 100644 index 0000000000000..c94c94d4a0b72 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/ServiceListMetric.tsx @@ -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 ( + + + + + + {valueLabel} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js index 927779b571fd8..519d74827097b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js @@ -15,34 +15,62 @@ describe('ServiceOverview -> List', () => { mockMoment(); }); - it('should render empty state', () => { + it('renders empty state', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - it('should render with data', () => { + it('renders with data', () => { const wrapper = shallow(); 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( + + ); + + const columns = wrapper.props().columns; + + expect(columns[0].field).not.toBe('severity'); + }); + }); + + describe('with ML data', () => { + it('renders health column', () => { + const wrapper = shallow( + + ); + + const columns = wrapper.props().columns; + + expect(columns[0].field).toBe('severity'); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap index 146f6f58031bb..da3f6ae89940a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap @@ -1,21 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ServiceOverview -> List should render columns correctly 1`] = ` - - - opbeans-python - - -`; +exports[`ServiceOverview -> List renders columns correctly 1`] = ``; -exports[`ServiceOverview -> List should render empty state 1`] = ` +exports[`ServiceOverview -> List renders empty state 1`] = ` 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`] = ` 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]} /> `; diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json index 2379d27407e04..7f24ad8b0d308 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json @@ -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": [] } ] diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index 90cc9af45273e..ce256137481cb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -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; + 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> = [ + { + field: 'severity', + name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { + defaultMessage: 'Health', + }), + width: px(unit * 6), + sortable: true, + render: (_, { severity }) => { + return ; + }, + }, { field: 'serviceName', name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { @@ -49,9 +70,24 @@ export const SERVICE_COLUMNS = [ }), width: '40%', sortable: true, - render: (serviceName: string) => ( - - {formatString(serviceName)} + render: (_, { serviceName, agentName }) => ( + + + {agentName && ( + + + + )} + + + {formatString(serviceName)} + + + ), }, @@ -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[]) => ( - + render: (_, { 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 }) => ( + + ), + 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 }) => ( + + ), + 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 ( + + ); + }, + 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 ( { + // 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 + ); + }} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index d9c5ff5130df6..8eeff018ad03f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -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); +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 ( - - {children} - + + + {children} + + ); } @@ -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); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index 6d447887627bf..b56f7d6820274 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -7,7 +7,7 @@ NodeList [ >
- Name + Health
- - My Go Service - + + Unknown + +
- Environment + Name
+ > + + + +
- Agent + Environment
- go + + + + test + + + + + + + dev + + +
- 0.6 ms +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 ms +
+
- 400.0 tpm +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 tpm +
+
- Errors per minute + Error rate %
- 500.0 err. +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+
, @@ -247,87 +423,91 @@ NodeList [ >
- Name + Health
- - My Python Service - + + Unknown + +
- Environment + Name
- - - test - - - - - - +
+
- dev - - + + My Go Service + +
+
- Agent + Environment
- python -
+ />
- 0.3 ms +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 ms +
+
- 100.0 tpm +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+ 0 tpm +
+
- Errors per minute + Error rate %
- 200.0 err. +
+
+
+
+
+
+
+
+
+ N/A +
+
+
+
+
+
+
, diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx index 7146e471a7f82..d9d2cffb67620 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx @@ -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 ( <> @@ -101,17 +124,27 @@ export function ServiceOverview() { - - + {displayMlCallout ? ( + + setUserHasDismissedCallout(true)} /> + + ) : null} + + + + } /> - } - /> - + + + diff --git a/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts b/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts new file mode 100644 index 0000000000000..2475eecee8e34 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/AgentIcon/get_agent_icon.ts @@ -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]; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/dot-net.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/dot-net.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/go.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/go.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/java.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/java.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/nodejs.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/nodejs.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/php.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/php.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/python.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/python.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/ruby.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/ruby.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg b/x-pack/plugins/apm/public/components/shared/AgentIcon/icons/rumjs.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg rename to x-pack/plugins/apm/public/components/shared/AgentIcon/icons/rumjs.svg diff --git a/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx b/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx new file mode 100644 index 0000000000000..5646fc05bd28f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/AgentIcon/index.tsx @@ -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 {agentName}; +} diff --git a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx index 9fe52aab83641..9db563a0f6ba8 100644 --- a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx @@ -33,9 +33,22 @@ interface Props { hidePerPageOptions?: boolean; noItemsMessage?: React.ReactNode; sortItems?: boolean; + sortFn?: ( + items: T[], + sortField: string, + sortDirection: 'asc' | 'desc' + ) => T[]; pagination?: boolean; } +function defaultSortFn( + items: T[], + sortField: string, + sortDirection: 'asc' | 'desc' +) { + return orderBy(items, sortField, sortDirection); +} + function UnoptimizedManagedTable(props: Props) { const history = useHistory(); const { @@ -48,6 +61,7 @@ function UnoptimizedManagedTable(props: Props) { hidePerPageOptions = true, noItemsMessage, sortItems = true, + sortFn = defaultSortFn, pagination = true, } = props; @@ -62,11 +76,11 @@ function UnoptimizedManagedTable(props: Props) { 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 { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx index fcbdb900368ea..5bddfc67200b1 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx @@ -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]; diff --git a/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx new file mode 100644 index 0000000000000..18b914afea995 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/SparkPlot/index.tsx @@ -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 ( + + + + + + + {NOT_AVAILABLE_LABEL} + + + + ); + } + + return ( + + + + + ); +} diff --git a/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts b/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts new file mode 100644 index 0000000000000..56c58bc82967b --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts @@ -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 } + ); +} diff --git a/x-pack/plugins/apm/public/hooks/useLocalStorage.ts b/x-pack/plugins/apm/public/hooks/useLocalStorage.ts new file mode 100644 index 0000000000000..cf37b45045f4d --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/useLocalStorage.ts @@ -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(key: string, defaultValue: T) { + const [item, setItem] = useState(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; +} diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json index 64602bc6b2769..f1643608496ad 100644 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ b/x-pack/plugins/apm/scripts/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../../../tsconfig.base.json", "include": [ - "./**/*" + "./**/*", + "../observability" ], "exclude": [], "compilerOptions": { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index e7eb7b8de65e3..93af51b572aa5 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -81,6 +81,11 @@ export function registerTransactionDurationAnomalyAlertType({ anomalyDetectors, alertParams.environment ); + + if (mlJobIds.length === 0) { + return {}; + } + const anomalySearchParams = { body: { size: 0, diff --git a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts index 75b0471424e79..5b78d97d5b681 100644 --- a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts @@ -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', }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts index 9f5b5cdf47552..ea018868f9517 100644 --- a/x-pack/plugins/apm/server/lib/helpers/metrics.ts +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.ts @@ -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', diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 6b69e57389dff..eba75433a5148 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -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( 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( } function getMlSetup( - ml: APMRequestHandlerContext['plugins']['ml'], + ml: Required['ml'], savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'], request: KibanaRequest ) { - if (!ml) { - return; - } - return { mlSystem: ml.mlSystemProvider(request), anomalyDetectors: ml.anomalyDetectorsProvider(request), diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index 551384da2cca7..d7e64bdcacd12 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -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 diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index ec274d20b6005..ed8ae923e6e6c 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -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 { - 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]; } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index d1c99d778c8f0..1e26b6f3f58f9 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index ca86c1d93fa6e..c5e072e073992 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -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", + ], + }, + }, ], }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts index d888b43b63fac..50a968467fb4b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -15,15 +15,22 @@ import { getTransactionDurationAverages, getAgentNames, getTransactionRates, - getErrorRates, + getTransactionErrorRates, getEnvironments, + getHealthStatuses, } from './get_services_items_stats'; export type ServiceListAPIResponse = PromiseReturnType; export type ServicesItemsSetup = Setup & SetupTimeRange & SetupUIFilters; export type ServicesItemsProjection = ReturnType; -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'); diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index ddce3b667a603..ab6b61ca21746 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -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,6 +172,11 @@ export const getTransactionRates = async ({ ...projection.body.aggs.services.terms, size: MAX_NUMBER_OF_SERVICES, }, + aggs: { + timeseries: { + date_histogram: getDateHistogramOpts(start, end), + }, + }, }, }, }, @@ -150,33 +191,67 @@ export const getTransactionRates = async ({ const deltaAsMinutes = getDeltaAsMinutes(setup); - return aggregations.services.buckets.map((bucket) => { - const transactionsPerMinute = bucket.doc_count / deltaAsMinutes; + return aggregations.services.buckets.map((serviceBucket) => { + const transactionsPerMinute = serviceBucket.doc_count / deltaAsMinutes; return { - serviceName: bucket.key as string, - transactionsPerMinute, + serviceName: serviceBucket.key as string, + transactionsPerMinute: { + value: transactionsPerMinute, + timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count / deltaAsMinutes, + })), + }, }; }); }; -export const getErrorRates = async ({ +export const getTransactionErrorRates = async ({ setup, projection, }: AggregationParams) => { - const { apmEventClient } = setup; + const { apmEventClient, start, end } = setup; + + const outcomes = { + terms: { + field: EVENT_OUTCOME, + }, + }; + const response = await apmEventClient.search( mergeProjection(projection, { apm: { - events: [ProcessorEvent.error], + 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, + }, + }, + }, }, }, }, @@ -189,13 +264,36 @@ export const getErrorRates = async ({ return []; } - const deltaAsMinutes = getDeltaAsMinutes(setup); + function calculateTransactionErrorPercentage( + outcomeResponse: AggregationResultOf + ) { + 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 aggregations.services.buckets.map((bucket) => { - const errorsPerMinute = bucket.doc_count / deltaAsMinutes; + return failedTransactions / (successfulTransactions + failedTransactions); + } + + return aggregations.services.buckets.map((serviceBucket) => { + const transactionErrorRate = calculateTransactionErrorPercentage( + serviceBucket.outcomes + ); return { - serviceName: bucket.key as string, - errorsPerMinute, + 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, + }; + }); +}; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/index.ts b/x-pack/plugins/apm/server/lib/services/get_services/index.ts index 5a909ebd6ec54..28b4c64a4af47 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/index.ts @@ -17,11 +17,15 @@ import { getServicesItems } from './get_services_items'; export type ServiceListAPIResponse = PromiseReturnType; -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), ]); diff --git a/x-pack/plugins/apm/server/lib/services/queries.test.ts b/x-pack/plugins/apm/server/lib/services/queries.test.ts index 99c58a17d396a..9b0dd7a03ca5b 100644 --- a/x-pack/plugins/apm/server/lib/services/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/services/queries.test.ts @@ -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]); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index f7b7f72168160..1e08b04416e17 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -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 }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts index f68082dfaa1e1..51118278fb824 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts @@ -24,7 +24,7 @@ export type ESResponse = PromiseReturnType; 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 } }] diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts index 596c3137ec19f..d8865f0049d35 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts @@ -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, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index 1498c22e327d6..f39529b59caa6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -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 } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts index 8a0fe1a57736f..ea06bd57bfff2 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts @@ -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); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 971e247d98986..8533d54ed6277 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -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; diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 74ab717b8de59..cc7f25867df2c 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -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; }, diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index ac25f22751f2f..290e81bd29973 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -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); } diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 7a7592b248960..bbd2c9eb86249 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -346,6 +346,12 @@ export type ValidAggregationKeysOf< T extends Record > = keyof (UnionToIntersection extends never ? T : UnionToIntersection); +export type AggregationResultOf< + TAggregationOptionsMap extends AggregationOptionsMap, + TDocument +> = AggregationResponsePart[AggregationType & + ValidAggregationKeysOf]; + export type AggregationResponseMap< TAggregationInputMap extends AggregationInputMap | undefined, TDocument diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 13f7159ba6043..b5bfe3eec7d35 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -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; } diff --git a/x-pack/plugins/observability/public/hooks/use_theme.tsx b/x-pack/plugins/observability/public/hooks/use_theme.tsx new file mode 100644 index 0000000000000..d0449a4432d93 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_theme.tsx @@ -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; +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 03939736b64ae..0aecea59ad013 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -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'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 603723111c051..cb6634b0202a6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4858,12 +4858,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": "サービスが見つかりません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d7d3e63ffd8bc..3858b84b279c2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4861,12 +4861,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": "未找到任何服务", diff --git a/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts b/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts index e4cceca573ce8..a87d080e564a2 100644 --- a/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts +++ b/x-pack/test/apm_api_integration/basic/tests/services/agent_name.ts @@ -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( diff --git a/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts b/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts index 8d91f4542e454..116b2987db32a 100644 --- a/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/basic/tests/services/top_services.ts @@ -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; + 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, - }, - ] - `); - - expect(response.body.hasHistoricalData).to.be(true); - expect(response.body.hasLegacyData).to.be(false); + Array [ + "testing", + ], + Array [ + "production", + ], + Array [ + "production", + ], + Array [ + "testing", + ], + ] + `); + }); + + 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); + }); }); }); }); diff --git a/x-pack/test/apm_api_integration/trial/tests/index.ts b/x-pack/test/apm_api_integration/trial/tests/index.ts index 48ffa13012696..c5ca086b5f370 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -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 () { diff --git a/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts new file mode 100644 index 0000000000000..76af02ec1606e --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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; + 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, + ] + `); + }); + }); + }); + }); +}