From 44a2203b91d9e30b9e949c217d61550dd83c76d8 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 16 Nov 2020 13:14:38 +0100 Subject: [PATCH 1/6] [APM] Service overview dependencies table Closes #83152. --- .../apm/common/elasticsearch_fieldnames.ts | 5 + .../apm/common/utils/formatters/duration.ts | 28 +- .../apm/common/utils/join_by_key/index.ts | 30 +- .../app/ServiceMap/cytoscape_options.ts | 6 +- .../public/components/app/ServiceMap/icons.ts | 67 +-- .../components/app/service_overview/index.tsx | 27 +- .../index.tsx | 247 ++++++++++ .../service_overview_errors_table/index.tsx | 32 +- .../components/shared/ImpactBar/index.tsx | 16 +- .../shared/span_icon/get_span_icon.ts | 72 +++ .../span_icon}/icons/aws.svg | 0 .../span_icon}/icons/cassandra.svg | 0 .../span_icon}/icons/database.svg | 0 .../span_icon}/icons/default.svg | 0 .../span_icon}/icons/documents.svg | 0 .../span_icon}/icons/elasticsearch.svg | 0 .../span_icon}/icons/globe.svg | 0 .../span_icon}/icons/graphql.svg | 0 .../span_icon}/icons/grpc.svg | 0 .../span_icon}/icons/handlebars.svg | 0 .../span_icon}/icons/kafka.svg | 0 .../span_icon}/icons/mongodb.svg | 0 .../span_icon}/icons/mysql.svg | 0 .../span_icon}/icons/postgresql.svg | 0 .../span_icon}/icons/redis.svg | 0 .../span_icon}/icons/websocket.svg | 0 .../components/shared/span_icon/index.tsx | 19 + .../table_fetch_wrapper/index.tsx} | 8 +- .../shared/truncate_with_tooltip/index.tsx | 39 ++ .../get_service_dependencies/index.ts | 435 ++++++++++++++++++ .../apm/server/routes/create_apm_api.ts | 2 + x-pack/plugins/apm/server/routes/services.ts | 26 ++ .../apm/typings/es_schemas/raw/metric_raw.ts | 17 + .../apm/typings/es_schemas/raw/span_raw.ts | 5 + 34 files changed, 957 insertions(+), 124 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/aws.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/cassandra.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/database.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/default.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/documents.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/elasticsearch.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/globe.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/graphql.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/grpc.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/handlebars.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/kafka.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/mongodb.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/mysql.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/postgresql.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/redis.svg (100%) rename x-pack/plugins/apm/public/components/{app/ServiceMap => shared/span_icon}/icons/websocket.svg (100%) create mode 100644 x-pack/plugins/apm/public/components/shared/span_icon/index.tsx rename x-pack/plugins/apm/public/components/{app/service_overview/service_overview_errors_table/fetch_wrapper.tsx => shared/table_fetch_wrapper/index.tsx} (71%) create mode 100644 x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx create mode 100644 x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index cc6a1fffb2288..18b8dc57c88db 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -61,6 +61,11 @@ export const SPAN_NAME = 'span.name'; export const SPAN_ID = 'span.id'; export const SPAN_DESTINATION_SERVICE_RESOURCE = 'span.destination.service.resource'; +export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT = + 'span.destination.service.response_time.count'; + +export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM = + 'span.destination.service.response_time.sum.us'; // Parent ID for a transaction or span export const PARENT_ID = 'parent.id'; diff --git a/x-pack/plugins/apm/common/utils/formatters/duration.ts b/x-pack/plugins/apm/common/utils/formatters/duration.ts index c0a99e0152fa7..42641290c1dd3 100644 --- a/x-pack/plugins/apm/common/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; -import { memoize } from 'lodash'; +import { memoize, isNumber } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; -import { asDecimalOrInteger, asInteger } from './formatters'; +import { asDecimalOrInteger, asInteger, asDecimal } from './formatters'; import { TimeUnit } from './datetime'; import { Maybe } from '../../../typings/common'; @@ -143,6 +143,29 @@ export const getDurationFormatter: TimeFormatterBuilder = memoize( } ); +export function asTransactionRate(value: Maybe) { + if (!isNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + let displayedValue: string; + + if (value === 0) { + displayedValue = '0'; + } else if (value <= 0.1) { + displayedValue = '< 0.1'; + } else { + displayedValue = asDecimal(value); + } + + return i18n.translate('xpack.apm.transactionRateLabel', { + defaultMessage: `{value} tpm`, + values: { + value: displayedValue, + }, + }); +} + /** * Converts value and returns it formatted - 00 unit */ @@ -157,7 +180,6 @@ export function asDuration( const formatter = getDurationFormatter(value); return formatter(value, { defaultValue }).formatted; } - /** * Convert a microsecond value to decimal milliseconds. Normally we use * `asDuration`, but this is used in places like tables where we always want diff --git a/x-pack/plugins/apm/common/utils/join_by_key/index.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.ts index b49f536400514..be415bf2f35d6 100644 --- a/x-pack/plugins/apm/common/utils/join_by_key/index.ts +++ b/x-pack/plugins/apm/common/utils/join_by_key/index.ts @@ -26,21 +26,41 @@ type JoinedReturnType< T extends Record, U extends UnionToIntersection, V extends keyof T & keyof U -> = Array & Record>; +> = Array< + Partial & + { + [k in keyof T]: T[k]; + } +>; export function joinByKey< T extends Record, U extends UnionToIntersection, V extends keyof T & keyof U ->(items: T[], key: V): JoinedReturnType { - return items.reduce>((prev, current) => { +>(items: T[], key: V): JoinedReturnType; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends keyof T & keyof U, + W extends JoinedReturnType, + X extends (a: T, b: T) => ValuesType +>(items: T[], key: V, mergeFn: X): W; + +export function joinByKey( + items: Array>, + key: string, + mergeFn: Function = (a: Record, b: Record) => + Object.assign(a, b) +) { + return items.reduce>>((prev, current) => { let item = prev.find((prevItem) => isEqual(prevItem[key], current[key])); if (!item) { - item = { ...current } as ValuesType>; + item = { ...current }; prev.push(item); } else { - Object.assign(item, current); + item = mergeFn(item, current); } return prev; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts index d8a8a3c8e9ab4..bea8cca6acded 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts @@ -16,7 +16,7 @@ import { ServiceHealthStatus, } from '../../../../common/service_health_status'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { defaultIcon, iconForNode } from './icons'; +import { iconForNode } from './icons'; export const popoverWidth = 280; @@ -116,9 +116,7 @@ const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => { 'background-color': theme.eui.euiColorGhost, // The DefinitelyTyped definitions don't specify that a function can be // used here. - 'background-image': isIE11 - ? undefined - : (el: cytoscape.NodeSingular) => iconForNode(el) ?? defaultIcon, + 'background-image': (el: cytoscape.NodeSingular) => iconForNode(el), 'background-height': (el: cytoscape.NodeSingular) => isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => 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 c85cf85d38702..e64c84f130c46 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -10,73 +10,8 @@ import { SPAN_SUBTYPE, SPAN_TYPE, } from '../../../../common/elasticsearch_fieldnames'; -import awsIcon from './icons/aws.svg'; -import cassandraIcon from './icons/cassandra.svg'; -import databaseIcon from './icons/database.svg'; -import defaultIconImport from './icons/default.svg'; -import documentsIcon from './icons/documents.svg'; -import elasticsearchIcon from './icons/elasticsearch.svg'; -import globeIcon from './icons/globe.svg'; -import graphqlIcon from './icons/graphql.svg'; -import grpcIcon from './icons/grpc.svg'; -import handlebarsIcon from './icons/handlebars.svg'; -import kafkaIcon from './icons/kafka.svg'; -import mongodbIcon from './icons/mongodb.svg'; -import mysqlIcon from './icons/mysql.svg'; -import postgresqlIcon from './icons/postgresql.svg'; -import redisIcon from './icons/redis.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; - -const defaultTypeIcons: { [key: string]: string } = { - cache: databaseIcon, - db: databaseIcon, - ext: globeIcon, - external: globeIcon, - messaging: documentsIcon, - resource: globeIcon, -}; - -const typeIcons: { [key: string]: { [key: string]: string } } = { - aws: { - servicename: awsIcon, - }, - db: { - cassandra: cassandraIcon, - elasticsearch: elasticsearchIcon, - mongodb: mongodbIcon, - mysql: mysqlIcon, - postgresql: postgresqlIcon, - redis: redisIcon, - }, - external: { - graphql: graphqlIcon, - grpc: grpcIcon, - websocket: websocketIcon, - }, - messaging: { - jms: javaIcon, - kafka: kafkaIcon, - }, - template: { - handlebars: handlebarsIcon, - }, -}; - -function getSpanIcon(type?: string, subtype?: string) { - if (!type) { - return; - } - - const types = type ? typeIcons[type] : {}; - if (subtype && types && subtype in types) { - return types[subtype]; - } - return defaultTypeIcons[type] || defaultIcon; -} +import { defaultIcon, getSpanIcon } from '../../shared/span_icon/get_span_icon'; // IE 11 does not properly load some SVGs, which causes a runtime error and the // map to not work at all. We would prefer to do some kind of feature detection diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 44bd7d6c73d8e..655b14f936806 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -18,10 +18,10 @@ import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; -import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { TransactionOverviewLink } from '../../shared/Links/apm/TransactionOverviewLink'; import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; +import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { TableLinkFlexItem } from './table_link_flex_item'; const rowHeight = 310; @@ -148,30 +148,7 @@ export function ServiceOverview({ - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableTitle', - { - defaultMessage: 'Dependencies', - } - )} -

-
-
- - - {i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableLinkText', - { - defaultMessage: 'View service map', - } - )} - - -
+
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx new file mode 100644 index 0000000000000..53e7104a3cd11 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -0,0 +1,247 @@ +/* + * 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 { EuiInMemoryTable } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { + asDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceDependencyItem } from '../../../../../server/lib/services/get_service_dependencies'; +import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; +import { useFetcher } from '../../../../hooks/useFetcher'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { callApmApi } from '../../../../services/rest/createCallApmApi'; +import { ServiceMapLink } from '../../../shared/Links/apm/ServiceMapLink'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; +import { TableLinkFlexItem } from '../table_link_flex_item'; +import { AgentIcon } from '../../../shared/AgentIcon'; +import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; +import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { px, unit } from '../../../../style/variables'; +import { ImpactBar } from '../../../shared/ImpactBar'; +import { ServiceOverviewLink } from '../../../shared/Links/apm/service_overview_link'; +import { SpanIcon } from '../../../shared/span_icon'; + +interface Props { + serviceName: string; +} + +export function ServiceOverviewDependenciesTable({ serviceName }: Props) { + const columns: Array> = [ + { + field: 'name', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnBackend', + { + defaultMessage: 'Backend', + } + ), + render: ( + _, + { name, agentName, serviceName: itemServiceName, spanType, spanSubtype } + ) => { + return ( + + + {agentName ? ( + + ) : ( + + )} + + + {itemServiceName ? ( + + {name} + + ) : ( + name + )} + + + } + /> + ); + }, + sortable: true, + }, + { + field: 'latency_value', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnLatency', + { + defaultMessage: 'Latency', + } + ), + width: px(unit * 10), + render: (_, { latency }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'traffic_value', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnTraffic', + { + defaultMessage: 'Traffic', + } + ), + width: px(unit * 10), + render: (_, { traffic }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'error_rate_value', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnErrorRate', + { + defaultMessage: 'Error rate', + } + ), + width: px(unit * 10), + render: (_, { error_rate: errorRate }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'impact_value', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnImpact', + { + defaultMessage: 'Impact', + } + ), + width: px(unit * 4), + render: (_, { impact }) => { + return ; + }, + sortable: true, + }, + ]; + + const { + urlParams: { start, end, environment }, + } = useUrlParams(); + + const { data = [], status } = useFetcher(() => { + if (!start || !end) { + return; + } + + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/dependencies', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment: environment || ENVIRONMENT_ALL.value, + numBuckets: 20, + }, + }, + }); + }, [start, end, serviceName, environment]); + + const items = data.map((item) => ({ + ...item, + error_rate_value: item.error_rate.value, + latency_value: item.latency.value, + traffic_value: item.traffic.value, + impact_value: item.impact, + })); + + return ( + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableTitle', + { + defaultMessage: 'Dependencies', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableLinkText', + { + defaultMessage: 'View service map', + } + )} + + +
+
+ + 0} status={status}> + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index a5a002cf3aca4..6e38355f22d03 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -3,26 +3,28 @@ * 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, { useState } from 'react'; -import { EuiTitle } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable } from '@elastic/eui'; -import { EuiBasicTableColumn } from '@elastic/eui'; +import React, { useState } from 'react'; import styled from 'styled-components'; -import { EuiToolTip } from '@elastic/eui'; import { asInteger } from '../../../../../common/utils/formatters'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink'; -import { TableLinkFlexItem } from '../table_link_flex_item'; -import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; import { px, truncate, unit } from '../../../../style/variables'; -import { FetchWrapper } from './fetch_wrapper'; +import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; +import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink'; +import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; +import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { TableLinkFlexItem } from '../table_link_flex_item'; interface Props { serviceName: string; @@ -223,7 +225,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { - + - + ); diff --git a/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx b/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx index ed931191cfb96..f5d71ad15f1ce 100644 --- a/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx @@ -10,11 +10,23 @@ import React from 'react'; // TODO: extend from EUI's EuiProgress prop interface export interface ImpactBarProps extends Record { value: number; + size?: 'l' | 'm'; max?: number; } -export function ImpactBar({ value, max = 100, ...rest }: ImpactBarProps) { +export function ImpactBar({ + value, + size = 'l', + max = 100, + ...rest +}: ImpactBarProps) { return ( - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts b/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts new file mode 100644 index 0000000000000..cb77be1c0f09c --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts @@ -0,0 +1,72 @@ +/* + * 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 awsIcon from './icons/aws.svg'; +import cassandraIcon from './icons/cassandra.svg'; +import databaseIcon from './icons/database.svg'; +import defaultIconImport from './icons/default.svg'; +import documentsIcon from './icons/documents.svg'; +import elasticsearchIcon from './icons/elasticsearch.svg'; +import globeIcon from './icons/globe.svg'; +import graphqlIcon from './icons/graphql.svg'; +import grpcIcon from './icons/grpc.svg'; +import handlebarsIcon from './icons/handlebars.svg'; +import kafkaIcon from './icons/kafka.svg'; +import mongodbIcon from './icons/mongodb.svg'; +import mysqlIcon from './icons/mysql.svg'; +import postgresqlIcon from './icons/postgresql.svg'; +import redisIcon from './icons/redis.svg'; +import websocketIcon from './icons/websocket.svg'; +import javaIcon from '../../shared/AgentIcon/icons/java.svg'; + +const defaultTypeIcons: { [key: string]: string } = { + cache: databaseIcon, + db: databaseIcon, + ext: globeIcon, + external: globeIcon, + messaging: documentsIcon, + resource: globeIcon, +}; + +const typeIcons: { [key: string]: { [key: string]: string } } = { + aws: { + servicename: awsIcon, + }, + db: { + cassandra: cassandraIcon, + elasticsearch: elasticsearchIcon, + mongodb: mongodbIcon, + mysql: mysqlIcon, + postgresql: postgresqlIcon, + redis: redisIcon, + }, + external: { + graphql: graphqlIcon, + grpc: grpcIcon, + websocket: websocketIcon, + }, + messaging: { + jms: javaIcon, + kafka: kafkaIcon, + }, + template: { + handlebars: handlebarsIcon, + }, +}; + +export const defaultIcon = defaultIconImport; + +export function getSpanIcon(type?: string, subtype?: string) { + if (!type) { + return; + } + + const types = type ? typeIcons[type] : {}; + if (subtype && types && subtype in types) { + return types[subtype]; + } + return defaultTypeIcons[type] || defaultIcon; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/aws.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/aws.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/aws.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/aws.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/cassandra.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/cassandra.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/database.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/database.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/database.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/database.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/default.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/default.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/default.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/default.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/documents.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/documents.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/documents.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/documents.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/elasticsearch.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/elasticsearch.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/globe.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/globe.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/globe.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/globe.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/graphql.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/graphql.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/grpc.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/grpc.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/handlebars.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/handlebars.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/kafka.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/kafka.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/mongodb.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/mongodb.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/mysql.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/mysql.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/postgresql.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/postgresql.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/redis.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/redis.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/redis.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/redis.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg b/x-pack/plugins/apm/public/components/shared/span_icon/icons/websocket.svg similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg rename to x-pack/plugins/apm/public/components/shared/span_icon/icons/websocket.svg diff --git a/x-pack/plugins/apm/public/components/shared/span_icon/index.tsx b/x-pack/plugins/apm/public/components/shared/span_icon/index.tsx new file mode 100644 index 0000000000000..98b076db65513 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_icon/index.tsx @@ -0,0 +1,19 @@ +/* + * 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 { px } from '../../../style/variables'; +import { getSpanIcon } from './get_span_icon'; + +interface Props { + type?: string; + subType?: string; +} + +export function SpanIcon({ type, subType }: Props) { + const icon = getSpanIcon(type, subType); + + return {type; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx b/x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx similarity index 71% rename from x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx rename to x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx index 4c8d368811a0c..a7e2d786515f6 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx +++ b/x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ErrorStatePrompt } from '../../../shared/ErrorStatePrompt'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { ErrorStatePrompt } from '../ErrorStatePrompt'; +import { LoadingStatePrompt } from '../LoadingStatePrompt'; -export function FetchWrapper({ +export function TableFetchWrapper({ hasData, status, children, diff --git a/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx b/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx new file mode 100644 index 0000000000000..4270d2a49bead --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import { truncate } from '../../../style/variables'; + +const TooltipWrapper = styled.div` + width: 100%; + .euiToolTipAnchor { + width: 100% !important; + display: block !important; + } +`; + +const ContentWrapper = styled.div` + ${truncate('100%')} +`; + +interface Props { + text: string; + content?: React.ReactNode; +} + +export function TruncateWithTooltip(props: Props) { + const { text, content } = props; + + return ( + + + {content || text} + + + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts new file mode 100644 index 0000000000000..78896300b172b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -0,0 +1,435 @@ +/* + * 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 { keyBy, sum } from 'lodash'; +import { ValuesType } from 'utility-types'; +import { EventOutcome } from '../../../../common/event_outcome'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { joinByKey } from '../../../../common/utils/join_by_key'; +import { ESSearchHit } from '../../../../../../typings/elasticsearch'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { + AGENT_NAME, + EVENT_OUTCOME, + PARENT_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + SPAN_ID, + SPAN_SUBTYPE, + SPAN_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { Span } from '../../../../typings/es_schemas/ui/span'; + +export interface ServiceDependencyItem { + name: string; + latency: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + traffic: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + error_rate: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + impact: number; + serviceName?: string; + environment?: string; + spanType?: string; + spanSubtype?: string; + agentName?: AgentName; +} + +const getMetrics = async ({ + start, + end, + apmEventClient, + serviceName, + environment, + numBuckets, +}: { + start: number; + end: number; + serviceName: string; + apmEventClient: APMEventClient; + environment: string; + numBuckets: number; +}) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT } }, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + aggs: { + connections: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + size: 100, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize(start, end, numBuckets) + .intervalString, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, + }, + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + }, + aggs: { + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.connections.buckets.map((bucket) => ({ + key: bucket.key as string, + value: { + count: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.count.value ?? 0 + ) + ), + latency_sum: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.latency_sum.value ?? 0 + ) + ), + error_count: sum( + bucket.timeseries.buckets.flatMap( + (dateBucket) => + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0 + ) + ), + }, + timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + count: dateBucket.count.value ?? 0, + latency_sum: dateBucket.latency_sum.value ?? 0, + error_count: + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0, + })), + })) ?? [] + ); +}; + +const getDestinationMap = async ({ + apmEventClient, + serviceName, + start, + end, + environment, +}: { + apmEventClient: APMEventClient; + serviceName: string; + start: number; + end: number; + environment: string; +}) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.span], + }, + body: { + size: 1000, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + collapse: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + inner_hits: { + name: EVENT_OUTCOME, + collapse: { field: EVENT_OUTCOME }, + size: 2, + _source: [SPAN_ID, SPAN_TYPE, SPAN_SUBTYPE], + }, + }, + _source: [SPAN_DESTINATION_SERVICE_RESOURCE], + }, + }); + + const outgoingConnections = response.hits.hits.flatMap((hit) => { + const dest = hit._source.span.destination!.service.resource; + const innerHits = hit.inner_hits as Record< + typeof EVENT_OUTCOME, + { hits: { hits: Array> } } + >; + return innerHits['event.outcome'].hits.hits.map((innerHit) => ({ + [SPAN_DESTINATION_SERVICE_RESOURCE]: dest, + id: innerHit._source.span.id, + [SPAN_TYPE]: innerHit._source.span.type, + [SPAN_SUBTYPE]: innerHit._source.span.subtype, + })); + }); + + const transactionResponse = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + { + terms: { + [PARENT_ID]: outgoingConnections.map( + (connection) => connection.id + ), + }, + }, + ], + }, + }, + size: outgoingConnections.length, + _source: [SERVICE_NAME, SERVICE_ENVIRONMENT, AGENT_NAME, PARENT_ID], + }, + }); + + const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ + id: hit._source.parent!.id, + service: { + name: hit._source.service.name, + environment: hit._source.service.environment, + agentName: hit._source.agent.name, + }, + })); + + const connections = joinByKey( + joinByKey( + [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], + 'id' + ), + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // map span.destination.service.resource to an instrumented service (service.name, service.environment) + // or an external service (span.type, span.subtype) + + return keyBy(connections, SPAN_DESTINATION_SERVICE_RESOURCE); +}; + +export async function getServiceDependencies({ + setup, + serviceName, + environment, + numBuckets, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment: string; + numBuckets: number; +}): Promise { + const { start, end, apmEventClient } = setup; + + const [allMetrics, destinationMap] = await Promise.all([ + getMetrics({ + start, + end, + apmEventClient, + serviceName, + environment, + numBuckets, + }), + getDestinationMap({ + apmEventClient, + serviceName, + start, + end, + environment, + }), + ]); + + const metricsWithMappedDestinations = allMetrics.map((metricItem) => { + const spanDestination = metricItem.key; + + const destination = destinationMap[spanDestination]; + + return { + destination: destination + ? destination + : { + [SPAN_DESTINATION_SERVICE_RESOURCE]: metricItem.key, + }, + metrics: [metricItem], + }; + }, []); + + const metricsJoinedByDestination = joinByKey( + metricsWithMappedDestinations, + 'destination', + (a, b) => ({ + ...a, + ...b, + metrics: [...(a.metrics ?? []), ...(b.metrics ?? [])], + }) + ); + + const metricsByResolvedAddress = metricsJoinedByDestination.map( + ({ destination, metrics }) => { + const mergedMetrics = metrics.reduce< + Omit, 'key'> + >( + (prev, current) => { + return { + value: { + count: prev.value.count + current.value.count, + latency_sum: prev.value.latency_sum + current.value.latency_sum, + error_count: prev.value.error_count + current.value.error_count, + }, + timeseries: joinByKey( + [...prev.timeseries, ...current.timeseries], + 'x', + (a, b) => ({ + x: a.x, + count: a.count + b.count, + latency_sum: a.latency_sum + b.latency_sum, + error_count: a.error_count + b.error_count, + }) + ), + }; + }, + { + value: { + count: 0, + latency_sum: 0, + error_count: 0, + }, + timeseries: [], + } + ); + + const deltaAsMinutes = (end - start) / 60 / 1000; + + const destMetrics = { + latency: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.latency_sum / mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.latency_sum / point.count : null, + })), + }, + traffic: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.count / deltaAsMinutes + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.count / deltaAsMinutes : null, + })), + }, + error_rate: { + value: + mergedMetrics.value.count > 0 + ? (mergedMetrics.value.error_count ?? 0) / + mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? (point.error_count ?? 0) / point.count : null, + })), + }, + }; + + if ('service' in destination) { + return { + name: destination.service!.name, + serviceName: destination.service!.name, + environment: destination.service!.environment, + agentName: destination.service!.agentName, + ...destMetrics, + }; + } + + return { + name: destination[SPAN_DESTINATION_SERVICE_RESOURCE], + spanType: + 'span.type' in destination ? destination[SPAN_TYPE] : undefined, + spanSubtype: + 'span.subtype' in destination ? destination[SPAN_SUBTYPE] : undefined, + ...destMetrics, + }; + } + ); + + const latencySums = metricsByResolvedAddress + .map((metrics) => metrics.latency.value) + .filter((n) => n !== null) as number[]; + + const minLatencySum = Math.min(...latencySums); + const maxLatencySum = Math.max(...latencySums); + + return metricsByResolvedAddress.map((metric) => ({ + ...metric, + impact: + metric.latency.value === null + ? 0 + : ((metric.latency.value - minLatencySum) / + (maxLatencySum - minLatencySum)) * + 100, + })); +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 34551c35ee234..d530a088259a6 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -22,6 +22,7 @@ import { serviceAnnotationsRoute, serviceAnnotationsCreateRoute, serviceErrorGroupsRoute, + serviceDependenciesRoute, } from './services'; import { agentConfigurationRoute, @@ -117,6 +118,7 @@ const createApmApi = () => { .add(serviceAnnotationsRoute) .add(serviceAnnotationsCreateRoute) .add(serviceErrorGroupsRoute) + .add(serviceDependenciesRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index ada1674d4555d..599cde915b921 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -18,6 +18,7 @@ import { getServiceAnnotations } from '../lib/services/annotations'; import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; +import { getServiceDependencies } from '../lib/services/get_service_dependencies'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; export const servicesRoute = createRoute(() => ({ @@ -239,3 +240,28 @@ export const serviceErrorGroupsRoute = createRoute(() => ({ }); }, })); + +export const serviceDependenciesRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/dependencies', + params: { + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + t.type({ environment: t.string, numBuckets: toNumberRt }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { serviceName } = context.params.path; + const { environment, numBuckets } = context.params.query; + return getServiceDependencies({ + serviceName, + environment, + setup, + numBuckets, + }); + }, +})); diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts index b4a1954783db0..b4f1576ed03c0 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts @@ -89,11 +89,28 @@ type TransactionDurationMetric = BaseMetric & { kubernetes?: Kubernetes; }; +export type SpanDestinationMetric = BaseMetric & { + span: { + destination: { + service: { + resource: string; + response_time: { + count: number; + sum: { + us: number; + }; + }; + }; + }; + }; +}; + export type MetricRaw = | BaseMetric | TransactionBreakdownMetric | SpanBreakdownMetric | TransactionDurationMetric + | SpanDestinationMetric | SystemMetric | CGroupMetric | JVMMetric; diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index dcb3dc02f6519..e152ed23af1b3 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -20,6 +20,11 @@ export interface SpanRaw extends APMBaseDoc { name: string; }; span: { + destination?: { + service: { + resource: string; + }; + }; action?: string; duration: { us: number }; id: string; From e35bb802f04af29ee7f6abfeb7cc7451d4444bee Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 2 Dec 2020 15:44:40 +0100 Subject: [PATCH 2/6] Use top_hits instead of collapse --- .../index.tsx | 45 ++-- .../service_overview_table.tsx | 2 +- .../annotations/get_stored_annotations.ts | 54 ++-- .../get_destination_map.ts | 148 +++++++++++ .../get_service_dependencies/get_metrics.ts | 135 ++++++++++ .../get_service_dependencies/index.ts | 242 +----------------- .../public/hooks/use_chart_theme.tsx | 4 + .../apm_api_integration/basic/tests/index.ts | 1 + .../tests/service_overview/dependencies.ts | 90 +++++++ .../typings/elasticsearch/aggregations.d.ts | 37 ++- x-pack/typings/elasticsearch/index.d.ts | 61 ++++- 11 files changed, 525 insertions(+), 294 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts create mode 100644 x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index cd0b003db62a0..77dc92e4c273f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -19,7 +19,7 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceDependencyItem } from '../../../../../server/lib/services/get_service_dependencies'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; -import { useFetcher } from '../../../../hooks/useFetcher'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { ServiceMapLink } from '../../../shared/Links/apm/ServiceMapLink'; @@ -32,6 +32,7 @@ import { px, unit } from '../../../../style/variables'; import { ImpactBar } from '../../../shared/ImpactBar'; import { ServiceOverviewLink } from '../../../shared/Links/apm/service_overview_link'; import { SpanIcon } from '../../../shared/span_icon'; +import { ServiceOverviewTableContainer } from '../service_overview_table'; interface Props { serviceName: string; @@ -100,21 +101,21 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sortable: true, }, { - field: 'traffic_value', + field: 'throughput_value', name: i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableColumnTraffic', + 'xpack.apm.serviceOverview.dependenciesTableColumnThroughput', { defaultMessage: 'Traffic', } ), width: px(unit * 10), - render: (_, { traffic }) => { + render: (_, { throughput }) => { return ( ); }, @@ -182,11 +183,12 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { }); }, [start, end, serviceName, environment]); + // need top-level sortable fields for the managed table const items = data.map((item) => ({ ...item, error_rate_value: item.error_rate.value, latency_value: item.latency.value, - traffic_value: item.traffic.value, + throughput_value: item.throughput.value, impact_value: item.impact, })); @@ -220,17 +222,24 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { - + + + diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx index b54458e4555f7..99753adfcd36d 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx @@ -21,7 +21,7 @@ const tableHeight = 298; * * Hide the empty message when we don't yet have any items and are still loading. */ -const ServiceOverviewTableContainer = styled.div<{ +export const ServiceOverviewTableContainer = styled.div<{ isEmptyAndLoading: boolean; }>` height: ${tableHeight}px; diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index 623abf6930297..4fb8a46a0b1a7 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -28,34 +28,36 @@ export async function getStoredAnnotations({ annotationsClient: ScopedAnnotationsClient; logger: Logger; }): Promise { - try { - const response: ESSearchResponse = (await apiCaller( - 'search', - { - index: annotationsClient.index, - body: { - size: 50, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: setup.start, - lt: setup.end, - }, - }, - }, - { term: { 'annotation.type': 'deployment' } }, - { term: { tags: 'apm' } }, - { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(environment), - ], + const body = { + size: 50, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: setup.start, + lt: setup.end, + }, }, }, - }, - } - )) as any; + { term: { 'annotation.type': 'deployment' } }, + { term: { tags: 'apm' } }, + { term: { [SERVICE_NAME]: serviceName } }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + }; + + try { + const response: ESSearchResponse< + ESAnnotation, + { body: typeof body } + > = (await apiCaller('search', { + index: annotationsClient.index, + body, + })) as any; return response.hits.hits.map((hit) => { return { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts new file mode 100644 index 0000000000000..813f785957881 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -0,0 +1,148 @@ +/* + * 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 { keyBy } from 'lodash'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { + AGENT_NAME, + EVENT_OUTCOME, + PARENT_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_ID, + SPAN_SUBTYPE, + SPAN_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { joinByKey } from '../../../../common/utils/join_by_key'; + +export const getDestinationMap = async ({ + apmEventClient, + serviceName, + start, + end, + environment, +}: { + apmEventClient: APMEventClient; + serviceName: string; + start: number; + end: number; + environment: string; +}) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.span], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + aggs: { + connections: { + composite: { + size: 1000, + sources: [ + { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { + terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, + }, + }, + { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, + ], + }, + aggs: { + docs: { + top_hits: { + docvalue_fields: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID] as const, + _source: false, + sort: { + '@timestamp': 'desc', + }, + }, + }, + }, + }, + }, + }, + }); + + const outgoingConnections = + response.aggregations?.connections.buckets.map((bucket) => { + const doc = bucket.docs.hits.hits[0]; + + return { + [SPAN_DESTINATION_SERVICE_RESOURCE]: String( + bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] + ), + id: String(doc.fields[SPAN_ID]?.[0]), + [SPAN_TYPE]: String(doc.fields[SPAN_TYPE]?.[0] ?? ''), + [SPAN_SUBTYPE]: String(doc.fields[SPAN_SUBTYPE]?.[0] ?? ''), + }; + }) ?? []; + + const transactionResponse = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + { + terms: { + [PARENT_ID]: outgoingConnections.map( + (connection) => connection.id + ), + }, + }, + ], + }, + }, + size: outgoingConnections.length, + docvalue_fields: [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + AGENT_NAME, + PARENT_ID, + ] as const, + _source: false, + }, + }); + + const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ + id: String(hit.fields[PARENT_ID]![0]), + service: { + name: String(hit.fields[SERVICE_NAME]![0]), + environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), + agentName: hit.fields[AGENT_NAME]![0] as AgentName, + }, + })); + + const connections = joinByKey( + joinByKey( + [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], + 'id' + ), + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // map span.destination.service.resource to an instrumented service (service.name, service.environment) + // or an external service (span.type, span.subtype) + + return keyBy(connections, SPAN_DESTINATION_SERVICE_RESOURCE); +}; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts new file mode 100644 index 0000000000000..46b12e67b2567 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -0,0 +1,135 @@ +/* + * 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 { sum } from 'lodash'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, +} from '../../../../common/elasticsearch_fieldnames'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { EventOutcome } from '../../../../common/event_outcome'; + +export const getMetrics = async ({ + start, + end, + apmEventClient, + serviceName, + environment, + numBuckets, +}: { + start: number; + end: number; + serviceName: string; + apmEventClient: APMEventClient; + environment: string; + numBuckets: number; +}) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT } }, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + aggs: { + connections: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + size: 100, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end, numBuckets }) + .intervalString, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, + }, + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + }, + aggs: { + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.connections.buckets.map((bucket) => ({ + key: bucket.key as string, + value: { + count: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.count.value ?? 0 + ) + ), + latency_sum: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.latency_sum.value ?? 0 + ) + ), + error_count: sum( + bucket.timeseries.buckets.flatMap( + (dateBucket) => + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0 + ) + ), + }, + timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + count: dateBucket.count.value ?? 0, + latency_sum: dateBucket.latency_sum.value ?? 0, + error_count: + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0, + })), + })) ?? [] + ); +}; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 26621dab3ac4d..f3a332dd65f80 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -3,32 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { keyBy, sum } from 'lodash'; import { ValuesType } from 'utility-types'; -import { EventOutcome } from '../../../../common/event_outcome'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { joinByKey } from '../../../../common/utils/join_by_key'; -import { ESSearchHit } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { - AGENT_NAME, - EVENT_OUTCOME, - PARENT_ID, - SERVICE_ENVIRONMENT, - SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE, - SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, - SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, - SPAN_ID, SPAN_SUBTYPE, SPAN_TYPE, } from '../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../common/processor_event'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { Span } from '../../../../typings/es_schemas/ui/span'; +import { getMetrics } from './get_metrics'; +import { getDestinationMap } from './get_destination_map'; export interface ServiceDependencyItem { name: string; @@ -36,7 +21,7 @@ export interface ServiceDependencyItem { value: number | null; timeseries: Array<{ x: number; y: number | null }>; }; - traffic: { + throughput: { value: number | null; timeseries: Array<{ x: number; y: number | null }>; }; @@ -52,223 +37,6 @@ export interface ServiceDependencyItem { agentName?: AgentName; } -const getMetrics = async ({ - start, - end, - apmEventClient, - serviceName, - environment, - numBuckets, -}: { - start: number; - end: number; - serviceName: string; - apmEventClient: APMEventClient; - environment: string; - numBuckets: number; -}) => { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], - }, - }, - aggs: { - connections: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, - size: 100, - }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ start, end, numBuckets }) - .intervalString, - extended_bounds: { - min: start, - max: end, - }, - }, - aggs: { - latency_sum: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, - }, - }, - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, - }, - }, - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, - }, - aggs: { - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }); - - return ( - response.aggregations?.connections.buckets.map((bucket) => ({ - key: bucket.key as string, - value: { - count: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.count.value ?? 0 - ) - ), - latency_sum: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.latency_sum.value ?? 0 - ) - ), - error_count: sum( - bucket.timeseries.buckets.flatMap( - (dateBucket) => - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0 - ) - ), - }, - timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - count: dateBucket.count.value ?? 0, - latency_sum: dateBucket.latency_sum.value ?? 0, - error_count: - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0, - })), - })) ?? [] - ); -}; - -const getDestinationMap = async ({ - apmEventClient, - serviceName, - start, - end, - environment, -}: { - apmEventClient: APMEventClient; - serviceName: string; - start: number; - end: number; - environment: string; -}) => { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.span], - }, - body: { - size: 1000, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], - }, - }, - collapse: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, - inner_hits: { - name: EVENT_OUTCOME, - collapse: { field: EVENT_OUTCOME }, - size: 2, - _source: [SPAN_ID, SPAN_TYPE, SPAN_SUBTYPE], - }, - }, - _source: [SPAN_DESTINATION_SERVICE_RESOURCE], - }, - }); - - const outgoingConnections = response.hits.hits.flatMap((hit) => { - const dest = hit._source.span.destination!.service.resource; - const innerHits = hit.inner_hits as Record< - typeof EVENT_OUTCOME, - { hits: { hits: Array> } } - >; - return innerHits['event.outcome'].hits.hits.map((innerHit) => ({ - [SPAN_DESTINATION_SERVICE_RESOURCE]: dest, - id: innerHit._source.span.id, - [SPAN_TYPE]: innerHit._source.span.type, - [SPAN_SUBTYPE]: innerHit._source.span.subtype, - })); - }); - - const transactionResponse = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - { - terms: { - [PARENT_ID]: outgoingConnections.map( - (connection) => connection.id - ), - }, - }, - ], - }, - }, - size: outgoingConnections.length, - _source: [SERVICE_NAME, SERVICE_ENVIRONMENT, AGENT_NAME, PARENT_ID], - }, - }); - - const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ - id: hit._source.parent!.id, - service: { - name: hit._source.service.name, - environment: hit._source.service.environment, - agentName: hit._source.agent.name, - }, - })); - - const connections = joinByKey( - joinByKey( - [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], - 'id' - ), - SPAN_DESTINATION_SERVICE_RESOURCE - ); - - // map span.destination.service.resource to an instrumented service (service.name, service.environment) - // or an external service (span.type, span.subtype) - - return keyBy(connections, SPAN_DESTINATION_SERVICE_RESOURCE); -}; - export async function getServiceDependencies({ setup, serviceName, @@ -321,7 +89,7 @@ export async function getServiceDependencies({ (a, b) => ({ ...a, ...b, - metrics: [...(a.metrics ?? []), ...(b.metrics ?? [])], + metrics: [...a.metrics, ...b.metrics], }) ); @@ -372,7 +140,7 @@ export async function getServiceDependencies({ y: point.count > 0 ? point.latency_sum / point.count : null, })), }, - traffic: { + throughput: { value: mergedMetrics.value.count > 0 ? mergedMetrics.value.count / deltaAsMinutes 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 fe668189dcf55..3880dcdcde0be 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -22,5 +22,9 @@ export function useChartTheme() { ...baseChartTheme.lineSeriesStyle, point: { visible: false }, }, + areaSeriesStyle: { + ...baseChartTheme.areaSeriesStyle, + point: { visible: false }, + }, }; } diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts index 27e9528a658a9..9eddf1c74a88c 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -26,6 +26,7 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont describe('Service overview', function () { loadTestFile(require.resolve('./service_overview/error_groups')); loadTestFile(require.resolve('./service_overview/transaction_groups')); + loadTestFile(require.resolve('./service_overview/dependencies')); }); describe('Settings', function () { diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts new file mode 100644 index 0000000000000..a7e0e885ec5bd --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts @@ -0,0 +1,90 @@ +/* + * 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 url from 'url'; +import { sortBy } from 'lodash'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { ENVIRONMENT_ALL } from '../../../../../plugins/apm/common/environment_filter_values'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import archives 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 { start, end } = archives[archiveName]; + + describe('Service overview dependencies', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/dependencies`, + query: { + start, + end, + numBuckets: 20, + environment: ENVIRONMENT_ALL.value, + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/dependencies`, + query: { + start, + end, + numBuckets: 20, + environment: ENVIRONMENT_ALL.value, + }, + }) + ); + + expect(response.status).to.be(200); + + const items: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'> = + response.body; + + expect(items.length).to.be.greaterThan(0); + + const names = items.map((item) => item.name); + + const serviceNames = items.map((item) => item.serviceName).filter(Boolean); + + const latencyValues = sortBy( + items.map((item) => ({ name: item.name, latency: item.latency.value })), + 'name' + ); + + const throughputValues = sortBy( + items.map((item) => ({ name: item.name, latency: item.throughput.value })), + 'name' + ); + + expectSnapshot(names.sort()).toMatchInline(); + + expectSnapshot(serviceNames.sort()).toMatchInline(); + + expectSnapshot(latencyValues).toMatchInline(); + + expectSnapshot(throughputValues).toMatchInline(); + }); + }); + }); +} diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index c63d85bd82dc0..acd36b0a78127 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -5,6 +5,7 @@ */ import { Unionize, UnionToIntersection } from 'utility-types'; +import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.'; type SortOrder = 'asc' | 'desc'; type SortInstruction = Record; @@ -21,8 +22,6 @@ type Script = type BucketsPath = string | Record; -type SourceOptions = string | string[]; - type AggregationSourceOptions = | { field: string; @@ -104,7 +103,9 @@ export interface AggregationOptionsByType { from?: number; size?: number; sort?: SortOptions; - _source?: SourceOptions; + _source?: ESSourceOptions; + fields?: MaybeReadonlyArray; + docvalue_fields?: MaybeReadonlyArray; }; filter: Record; filters: { @@ -178,6 +179,10 @@ export interface AggregationOptionsByType { }; script: string; }; + top_metrics: { + metrics: { field: string } | MaybeReadonlyArray<{ field: string }>; + sort: SortOptions; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -271,9 +276,9 @@ interface AggregationResponsePart; + hits: TAggregationOptionsMap extends { top_hits: AggregationOptionsByType['top_hits'] } + ? ESHitsOf + : ESSearchHit[]; }; }; filter: { @@ -369,8 +374,28 @@ interface AggregationResponsePart + : TAggregationOptionsMap extends { + top_metrics: { metrics: MaybeReadonlyArray<{ field: infer TFieldName }> }; + } + ? TopMetricsMap + : TopMetricsMap + >; + } + ]; } +type TopMetricsMap = TFieldName extends string + ? Record + : Record; + // Type for debugging purposes. If you see an error in AggregationResponseMap // similar to "cannot be used to index type", uncomment the type below and hover // over it to see what aggregation response types are missing compared to the diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index bbc29bdba8091..ff20ce39d6446 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -3,8 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { SearchParams, SearchResponse } from 'elasticsearch'; +import { ValuesType } from 'utility-types'; +import { Explanation, SearchParams, SearchResponse } from 'elasticsearch'; import { AggregationResponseMap, AggregationInputMap, SortOptions } from './aggregations'; export { AggregationInputMap, @@ -18,6 +18,8 @@ export { // Typings for Elasticsearch queries and aggregations. These are intended to be // moved to the Elasticsearch JS client at some point (see #77720.) +export type MaybeReadonlyArray = T[] | readonly T[]; + interface CollapseQuery { field: string; inner_hits: { @@ -38,6 +40,28 @@ interface CollapseQuery { max_concurrent_group_searches?: number; } +export type ESSourceOptions = boolean | string | string[]; + +export type ESHitsOf< + TOptions extends + | { + size?: number; + _source?: ESSourceOptions; + docvalue_fields?: MaybeReadonlyArray; + fields?: MaybeReadonlyArray; + } + | undefined, + TDocument extends unknown +> = Array< + ESSearchHit< + TOptions extends { _source: false } ? undefined : TDocument, + TOptions extends { fields: MaybeReadonlyArray } ? TOptions['fields'] : undefined, + TOptions extends { docvalue_fields: MaybeReadonlyArray } + ? TOptions['docvalue_fields'] + : undefined + > +>; + export interface ESSearchBody { query?: any; size?: number; @@ -45,7 +69,7 @@ export interface ESSearchBody { aggs?: AggregationInputMap; track_total_hits?: boolean | number; collapse?: CollapseQuery; - _source?: string | string[] | { excludes: string | string[] }; + _source?: ESSourceOptions; } export type ESSearchRequest = Omit & { @@ -56,7 +80,32 @@ export interface ESSearchOptions { restTotalHitsAsInt: boolean; } -export type ESSearchHit = SearchResponse['hits']['hits'][0]; +export type ESSearchHit< + TSource extends any = unknown, + TFields extends MaybeReadonlyArray | undefined = undefined, + TDocValueFields extends MaybeReadonlyArray | undefined = undefined +> = { + _index: string; + _type: string; + _id: string; + _score: number; + _version?: number; + _explanation?: Explanation; + highlight?: any; + inner_hits?: any; + matched_queries?: string[]; + sort?: string[]; +} & (TSource extends false ? {} : { _source: TSource }) & + (TFields extends MaybeReadonlyArray + ? { + fields: Partial, unknown[]>>; + } + : {}) & + (TDocValueFields extends MaybeReadonlyArray + ? { + fields: Partial, unknown[]>>; + } + : {}); export type ESSearchResponse< TDocument, @@ -68,7 +117,7 @@ export type ESSearchResponse< aggregations?: AggregationResponseMap; } : {}) & { - hits: Omit['hits'], 'total'> & + hits: Omit['hits'], 'total' | 'hits'> & (TOptions['restTotalHitsAsInt'] extends true ? { total: number; @@ -78,7 +127,7 @@ export type ESSearchResponse< value: number; relation: 'eq' | 'gte'; }; - }); + }) & { hits: ESHitsOf }; }; export interface ESFilter { From dab72cd0e169bc7e4e65021fd68f7f842ce00ba6 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 8 Dec 2020 09:29:45 +0100 Subject: [PATCH 3/6] Make sure result of mergeFn overrides existing item --- .../common/utils/join_by_key/index.test.ts | 33 +++++++++++++++++++ .../apm/common/utils/join_by_key/index.ts | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts index 458d21bfea58f..8adfe41b97eb8 100644 --- a/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts +++ b/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts @@ -101,4 +101,37 @@ describe('joinByKey', () => { }, ]); }); + + it('uses the custom merge fn to replace items', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-java', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['b'], + }, + { + serviceName: 'opbeans-node', + values: ['c'], + }, + ], + 'serviceName', + (a, b) => ({ + ...a, + ...b, + values: a.values.concat(b.values), + }) + ); + + expect( + joined.find((item) => item.serviceName === 'opbeans-node')?.values + ).toEqual(['a', 'b', 'c']); + }); }); diff --git a/x-pack/plugins/apm/common/utils/join_by_key/index.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.ts index e15f82eb0a790..f962c5cba47e7 100644 --- a/x-pack/plugins/apm/common/utils/join_by_key/index.ts +++ b/x-pack/plugins/apm/common/utils/join_by_key/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { UnionToIntersection, ValuesType } from 'utility-types'; -import { isEqual } from 'lodash'; +import { isEqual, pull } from 'lodash'; /** * Joins a list of records by a given key. Key can be any type of value, from @@ -59,7 +59,7 @@ export function joinByKey( item = { ...current }; prev.push(item); } else { - item = mergeFn(item, current); + pull(prev, item).push(mergeFn(item, current)); } return prev; From 84643269f2f67f25ee585bfb361a334b5340865d Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 8 Dec 2020 14:32:39 +0100 Subject: [PATCH 4/6] Review feedback --- .../index.tsx | 24 ++++---- .../shared/charts/spark_plot/index.tsx | 2 +- .../spark_plot_with_value_label/index.tsx | 2 +- .../shared/truncate_with_tooltip/index.tsx | 10 +++- .../annotations/get_stored_annotations.ts | 8 +-- .../get_destination_map.ts | 54 +++++++++++++---- .../get_service_dependencies/get_metrics.ts | 12 ++-- .../get_service_dependencies/index.ts | 58 +++++++++---------- .../tests/service_overview/dependencies.ts | 51 ++++++++++------ 9 files changed, 133 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 03533b5a4647f..7db5a61a3fc06 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -93,7 +93,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { return ( ); @@ -101,7 +101,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sortable: true, }, { - field: 'throughput_value', + field: 'throughputValue', name: i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableColumnThroughput', { @@ -114,7 +114,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { ); @@ -122,7 +122,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sortable: true, }, { - field: 'error_rate_value', + field: 'errorRateValue', name: i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableColumnErrorRate', { @@ -130,12 +130,12 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { } ), width: px(unit * 10), - render: (_, { error_rate: errorRate }) => { + render: (_, { errorRate }) => { return ( ); @@ -143,7 +143,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sortable: true, }, { - field: 'impact_value', + field: 'impactValue', name: i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableColumnImpact', { @@ -186,10 +186,10 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { // need top-level sortable fields for the managed table const items = data.map((item) => ({ ...item, - error_rate_value: item.error_rate.value, - latency_value: item.latency.value, - throughput_value: item.throughput.value, - impact_value: item.impact, + errorRateValue: item.errorRate.value, + latencyValue: item.latency.value, + throughputValue: item.throughput.value, + impactValue: item.impact, })); return ( @@ -235,7 +235,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sorting={{ sort: { direction: 'desc', - field: 'impact_value', + field: 'impactValue', }, }} /> diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 73a819af2d624..d2bc9cc609f51 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -21,7 +21,7 @@ import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; interface Props { color: string; - series?: Array<{ x: number; y: number | null }>; + series?: Array<{ x: number; y: number | null }> | null; width: string; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx index 3bfcba63685b6..7ca89c5a27504 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx @@ -29,7 +29,7 @@ export function SparkPlotWithValueLabel({ compact, }: { color: Color; - series?: Array<{ x: number; y: number | null }>; + series?: Array<{ x: number; y: number | null }> | null; valueLabel: React.ReactNode; compact?: boolean; }) { diff --git a/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx b/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx index 4270d2a49bead..04c4e893577f9 100644 --- a/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx @@ -9,9 +9,11 @@ import React from 'react'; import styled from 'styled-components'; import { truncate } from '../../../style/variables'; +const tooltipAnchorClassname = '_apm_truncate_tooltip_anchor_'; + const TooltipWrapper = styled.div` width: 100%; - .euiToolTipAnchor { + .${tooltipAnchorClassname} { width: 100% !important; display: block !important; } @@ -31,7 +33,11 @@ export function TruncateWithTooltip(props: Props) { return ( - + {content || text} diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index 4fb8a46a0b1a7..3903298415aed 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -5,6 +5,7 @@ */ import { LegacyAPICaller, Logger } from 'kibana/server'; +import { rangeFilter } from '../../../../common/utils/range_filter'; import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { Annotation as ESAnnotation } from '../../../../../observability/common/annotations'; import { ScopedAnnotationsClient } from '../../../../../observability/server'; @@ -34,12 +35,7 @@ export async function getStoredAnnotations({ bool: { filter: [ { - range: { - '@timestamp': { - gte: setup.start, - lt: setup.end, - }, - }, + range: rangeFilter(setup.start, setup.end), }, { term: { 'annotation.type': 'deployment' } }, { term: { tags: 'apm' } }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts index 813f785957881..2837d593cdaa2 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -20,22 +20,20 @@ import { import { rangeFilter } from '../../../../common/utils/range_filter'; import { ProcessorEvent } from '../../../../common/processor_event'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { joinByKey } from '../../../../common/utils/join_by_key'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export const getDestinationMap = async ({ - apmEventClient, + setup, serviceName, - start, - end, environment, }: { - apmEventClient: APMEventClient; + setup: Setup & SetupTimeRange; serviceName: string; - start: number; - end: number; environment: string; }) => { + const { start, end, apmEventClient } = setup; + const response = await apmEventClient.search({ apm: { events: [ProcessorEvent.span], @@ -62,6 +60,8 @@ export const getDestinationMap = async ({ terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, }, }, + // make sure we get samples for both successful + // and failed calls { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, ], }, @@ -110,6 +110,7 @@ export const getDestinationMap = async ({ ), }, }, + { range: rangeFilter(start, end) }, ], }, }, @@ -133,13 +134,42 @@ export const getDestinationMap = async ({ }, })); + const joinedBySpanId = joinByKey( + [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], + 'id' + ); + const connections = joinByKey( - joinByKey( - [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], - 'id' - ), + joinedBySpanId, SPAN_DESTINATION_SERVICE_RESOURCE - ); + ).map((connection) => { + const info = { + span: { + type: connection[SPAN_TYPE], + subtype: connection[SPAN_SUBTYPE], + destination: { + service: { + resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], + }, + }, + }, + }; + + return { + ...info, + ...('service' in connection && connection.service + ? { + service: { + name: connection.service.name, + environment: connection.service.environment, + }, + agent: { + name: connection.service.agentName, + }, + } + : {}), + }; + }); // map span.destination.service.resource to an instrumented service (service.name, service.environment) // or an external service (span.type, span.subtype) diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts index 46b12e67b2567..0b0760e71e12b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -15,25 +15,23 @@ import { import { rangeFilter } from '../../../../common/utils/range_filter'; import { ProcessorEvent } from '../../../../common/processor_event'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { EventOutcome } from '../../../../common/event_outcome'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export const getMetrics = async ({ - start, - end, - apmEventClient, + setup, serviceName, environment, numBuckets, }: { - start: number; - end: number; + setup: Setup & SetupTimeRange; serviceName: string; - apmEventClient: APMEventClient; environment: string; numBuckets: number; }) => { + const { start, end, apmEventClient } = setup; + const response = await apmEventClient.search({ apm: { events: [ProcessorEvent.metric], diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index f3a332dd65f80..7e55212fde836 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -4,13 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { ValuesType } from 'utility-types'; +import { isFiniteNumber } from '../../../../common/utils/is_finite_number'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { joinByKey } from '../../../../common/utils/join_by_key'; -import { - SPAN_DESTINATION_SERVICE_RESOURCE, - SPAN_SUBTYPE, - SPAN_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getMetrics } from './get_metrics'; import { getDestinationMap } from './get_destination_map'; @@ -25,7 +21,7 @@ export interface ServiceDependencyItem { value: number | null; timeseries: Array<{ x: number; y: number | null }>; }; - error_rate: { + errorRate: { value: number | null; timeseries: Array<{ x: number; y: number | null }>; }; @@ -48,22 +44,18 @@ export async function getServiceDependencies({ environment: string; numBuckets: number; }): Promise { - const { start, end, apmEventClient } = setup; + const { start, end } = setup; const [allMetrics, destinationMap] = await Promise.all([ getMetrics({ - start, - end, - apmEventClient, + setup, serviceName, environment, numBuckets, }), getDestinationMap({ - apmEventClient, + setup, serviceName, - start, - end, environment, }), ]); @@ -73,12 +65,18 @@ export async function getServiceDependencies({ const destination = destinationMap[spanDestination]; - return { - destination: destination - ? destination - : { - [SPAN_DESTINATION_SERVICE_RESOURCE]: metricItem.key, + const defaultInfo = { + span: { + destination: { + service: { + resource: metricItem.key, }, + }, + }, + }; + + return { + destination: destination ? destination : defaultInfo, metrics: [metricItem], }; }, []); @@ -150,7 +148,7 @@ export async function getServiceDependencies({ y: point.count > 0 ? point.count / deltaAsMinutes : null, })), }, - error_rate: { + errorRate: { value: mergedMetrics.value.count > 0 ? (mergedMetrics.value.error_count ?? 0) / @@ -165,20 +163,22 @@ export async function getServiceDependencies({ if ('service' in destination) { return { - name: destination.service!.name, - serviceName: destination.service!.name, - environment: destination.service!.environment, - agentName: destination.service!.agentName, + name: destination.service.name, + serviceName: destination.service.name, + environment: destination.service.environment, + agentName: destination.agent.name, ...destMetrics, }; } return { - name: destination[SPAN_DESTINATION_SERVICE_RESOURCE], - spanType: - 'span.type' in destination ? destination[SPAN_TYPE] : undefined, - spanSubtype: - 'span.subtype' in destination ? destination[SPAN_SUBTYPE] : undefined, + name: destination.span.destination.service.resource!, + ...('span' in destination && 'type' in destination.span + ? { + spanType: destination.span.type, + spanSubtype: destination.span.subtype, + } + : {}), ...destMetrics, }; } @@ -186,7 +186,7 @@ export async function getServiceDependencies({ const latencySums = metricsByResolvedAddress .map((metrics) => metrics.latency.value) - .filter((n) => n !== null) as number[]; + .filter(isFiniteNumber); const minLatencySum = Math.min(...latencySums); const maxLatencySum = Math.max(...latencySums); diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts index a7e0e885ec5bd..c57f343b8e051 100644 --- a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts @@ -39,12 +39,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - describe('when data is loaded', () => { - before(() => esArchiver.load(archiveName)); - after(() => esArchiver.unload(archiveName)); + // skip until es archive can be updated + describe.skip('when data is loaded', () => { + let response: { + status: number; + body: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'>; + }; - it('returns the correct data', async () => { - const response = await supertest.get( + before(async () => { + await esArchiver.load(archiveName); + + response = await supertest.get( url.format({ pathname: `/api/apm/services/opbeans-java/dependencies`, query: { @@ -55,34 +60,44 @@ export default function ApiTest({ getService }: FtrProviderContext) { }, }) ); + }); + + after(() => esArchiver.unload(archiveName)); + it('returns a successful response', () => { expect(response.status).to.be(200); + }); - const items: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'> = - response.body; + it('returns at least one item', () => { + expect(response.body.length).to.be.greaterThan(0); + }); - expect(items.length).to.be.greaterThan(0); + it('returns the right names', () => { + const names = response.body.map((item) => item.name); + expectSnapshot(names.sort()).toMatchInline(); + }); - const names = items.map((item) => item.name); + it('returns the right service names', () => { + const serviceNames = response.body.map((item) => item.serviceName).filter(Boolean); - const serviceNames = items.map((item) => item.serviceName).filter(Boolean); + expectSnapshot(serviceNames.sort()).toMatchInline(); + }); + it('returns the right latency values', () => { const latencyValues = sortBy( - items.map((item) => ({ name: item.name, latency: item.latency.value })), + response.body.map((item) => ({ name: item.name, latency: item.latency.value })), 'name' ); + expectSnapshot(latencyValues).toMatchInline(); + }); + + it('returns the right throughput values', () => { const throughputValues = sortBy( - items.map((item) => ({ name: item.name, latency: item.throughput.value })), + response.body.map((item) => ({ name: item.name, latency: item.throughput.value })), 'name' ); - expectSnapshot(names.sort()).toMatchInline(); - - expectSnapshot(serviceNames.sort()).toMatchInline(); - - expectSnapshot(latencyValues).toMatchInline(); - expectSnapshot(throughputValues).toMatchInline(); }); }); From a6687a099768e631691767c0e8ec535b55077a49 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 9 Dec 2020 11:05:07 +0100 Subject: [PATCH 5/6] Review feedback --- .../index.tsx | 30 ++++++------ .../shared/charts/spark_plot/index.tsx | 29 ++++++++---- .../shared/span_icon/get_span_icon.ts | 6 ++- .../get_service_dependencies/get_metrics.ts | 8 +++- .../get_service_dependencies/index.ts | 46 ++++++++----------- 5 files changed, 65 insertions(+), 54 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 7db5a61a3fc06..87ff702e0a960 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -48,29 +48,26 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { defaultMessage: 'Backend', } ), - render: ( - _, - { name, agentName, serviceName: itemServiceName, spanType, spanSubtype } - ) => { + render: (_, item) => { return ( - {agentName ? ( - + {item.type === 'service' ? ( + ) : ( - + )} - {itemServiceName ? ( - - {name} + {item.type === 'service' ? ( + + {item.name} ) : ( - name + item.name )} @@ -81,7 +78,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { sortable: true, }, { - field: 'latency_value', + field: 'latencyValue', name: i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableColumnLatency', { @@ -150,7 +147,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { defaultMessage: 'Impact', } ), - width: px(unit * 4), + width: px(unit * 5), render: (_, { impact }) => { return ; }, @@ -232,6 +229,11 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { items={items} allowNeutralSort={false} loading={status === FETCH_STATUS.LOADING} + pagination={{ + initialPageSize: 5, + pageSizeOptions: [5], + hidePerPageOptions: true, + }} sorting={{ sort: { direction: 'desc', diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index d2bc9cc609f51..ab1e725a08dff 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -3,21 +3,19 @@ * 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, + Chart, CurveType, + ScaleType, + Settings, } 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import React from 'react'; +import { merge } from 'lodash'; import { useChartTheme } from '../../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { px } from '../../../../style/variables'; interface Props { color: string; @@ -46,7 +44,18 @@ export function SparkPlot(props: Props) { return ( - + ({ - key: bucket.key as string, + span: { + destination: { + service: { + resource: String(bucket.key), + }, + }, + }, value: { count: sum( bucket.timeseries.buckets.map( diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 7e55212fde836..1e7701b92b333 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { ValuesType } from 'utility-types'; +import { merge } from 'lodash'; import { isFiniteNumber } from '../../../../common/utils/is_finite_number'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { joinByKey } from '../../../../common/utils/join_by_key'; @@ -11,7 +12,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getMetrics } from './get_metrics'; import { getDestinationMap } from './get_destination_map'; -export interface ServiceDependencyItem { +export type ServiceDependencyItem = { name: string; latency: { value: number | null; @@ -26,12 +27,15 @@ export interface ServiceDependencyItem { timeseries: Array<{ x: number; y: number | null }>; }; impact: number; - serviceName?: string; - environment?: string; - spanType?: string; - spanSubtype?: string; - agentName?: AgentName; -} +} & ( + | { + type: 'service'; + serviceName: string; + agentName: AgentName; + environment?: string; + } + | { type: 'external'; spanType?: string; spanSubtype?: string } +); export async function getServiceDependencies({ setup, @@ -61,22 +65,12 @@ export async function getServiceDependencies({ ]); const metricsWithMappedDestinations = allMetrics.map((metricItem) => { - const spanDestination = metricItem.key; + const spanDestination = metricItem.span.destination.service.resource; const destination = destinationMap[spanDestination]; - const defaultInfo = { - span: { - destination: { - service: { - resource: metricItem.key, - }, - }, - }, - }; - return { - destination: destination ? destination : defaultInfo, + destination: merge({ span: metricItem.span }, destination || {}), metrics: [metricItem], }; }, []); @@ -94,7 +88,7 @@ export async function getServiceDependencies({ const metricsByResolvedAddress = metricsJoinedByDestination.map( ({ destination, metrics }) => { const mergedMetrics = metrics.reduce< - Omit, 'key'> + Omit, 'span'> >( (prev, current) => { return { @@ -164,6 +158,7 @@ export async function getServiceDependencies({ if ('service' in destination) { return { name: destination.service.name, + type: 'service' as const, serviceName: destination.service.name, environment: destination.service.environment, agentName: destination.agent.name, @@ -172,13 +167,10 @@ export async function getServiceDependencies({ } return { - name: destination.span.destination.service.resource!, - ...('span' in destination && 'type' in destination.span - ? { - spanType: destination.span.type, - spanSubtype: destination.span.subtype, - } - : {}), + name: destination.span.destination.service.resource, + type: 'external' as const, + spanType: destination.span.type, + spanSubtype: destination.span.subtype, ...destMetrics, }; } From 70b6b725a55ec4b777fce9e19be3c0d9a7c08e98 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 9 Dec 2020 19:27:40 +0100 Subject: [PATCH 6/6] API tests --- .../common/utils/join_by_key/index.test.ts | 28 ++ .../apm/common/utils/join_by_key/index.ts | 17 +- .../get_destination_map.ts | 61 ++- .../get_service_dependencies/get_metrics.ts | 1 + .../get_service_dependencies/index.ts | 204 +++++----- .../tests/service_overview/dependencies.ts | 105 ----- .../service_overview/dependencies/es_utils.ts | 216 ++++++++++ .../service_overview/dependencies/index.ts | 385 ++++++++++++++++++ .../basic/tests/services/top_services.ts | 96 ++--- .../trial/tests/services/top_services.ts | 12 +- 10 files changed, 852 insertions(+), 273 deletions(-) delete mode 100644 x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/es_utils.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/index.ts diff --git a/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts index 8adfe41b97eb8..59109c720e9c9 100644 --- a/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts +++ b/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts @@ -134,4 +134,32 @@ describe('joinByKey', () => { joined.find((item) => item.serviceName === 'opbeans-node')?.values ).toEqual(['a', 'b', 'c']); }); + + it('deeply merges objects', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + properties: { + foo: '', + }, + }, + { + serviceName: 'opbeans-node', + properties: { + bar: '', + }, + }, + ], + 'serviceName' + ); + + expect(joined[0]).toEqual({ + serviceName: 'opbeans-node', + properties: { + foo: '', + bar: '', + }, + }); + }); }); diff --git a/x-pack/plugins/apm/common/utils/join_by_key/index.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.ts index f962c5cba47e7..6678bf68afbae 100644 --- a/x-pack/plugins/apm/common/utils/join_by_key/index.ts +++ b/x-pack/plugins/apm/common/utils/join_by_key/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { UnionToIntersection, ValuesType } from 'utility-types'; -import { isEqual, pull } from 'lodash'; +import { isEqual, pull, merge, castArray } from 'lodash'; /** * Joins a list of records by a given key. Key can be any type of value, from @@ -32,28 +32,33 @@ type JoinedReturnType< } >; +type ArrayOrSingle = T | T[]; + export function joinByKey< T extends Record, U extends UnionToIntersection, - V extends keyof T & keyof U + V extends ArrayOrSingle >(items: T[], key: V): JoinedReturnType; export function joinByKey< T extends Record, U extends UnionToIntersection, - V extends keyof T & keyof U, + V extends ArrayOrSingle, W extends JoinedReturnType, X extends (a: T, b: T) => ValuesType >(items: T[], key: V, mergeFn: X): W; export function joinByKey( items: Array>, - key: string, + key: string | string[], mergeFn: Function = (a: Record, b: Record) => - Object.assign(a, b) + merge({}, a, b) ) { + const keys = castArray(key); return items.reduce>>((prev, current) => { - let item = prev.find((prevItem) => isEqual(prevItem[key], current[key])); + let item = prev.find((prevItem) => + keys.every((k) => isEqual(prevItem[k], current[k])) + ); if (!item) { item = { ...current }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts index 2837d593cdaa2..d6198e2d3b65a 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keyBy } from 'lodash'; +import { isEqual, keyBy, mapValues } from 'lodash'; +import { pickKeys } from '../../../../common/utils/pick_keys'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { AGENT_NAME, @@ -89,7 +90,7 @@ export const getDestinationMap = async ({ [SPAN_DESTINATION_SERVICE_RESOURCE]: String( bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] ), - id: String(doc.fields[SPAN_ID]?.[0]), + [SPAN_ID]: String(doc.fields[SPAN_ID]?.[0]), [SPAN_TYPE]: String(doc.fields[SPAN_TYPE]?.[0] ?? ''), [SPAN_SUBTYPE]: String(doc.fields[SPAN_SUBTYPE]?.[0] ?? ''), }; @@ -106,7 +107,7 @@ export const getDestinationMap = async ({ { terms: { [PARENT_ID]: outgoingConnections.map( - (connection) => connection.id + (connection) => connection[SPAN_ID] ), }, }, @@ -126,7 +127,7 @@ export const getDestinationMap = async ({ }); const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ - id: String(hit.fields[PARENT_ID]![0]), + [SPAN_ID]: String(hit.fields[PARENT_ID]![0]), service: { name: String(hit.fields[SERVICE_NAME]![0]), environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), @@ -134,16 +135,49 @@ export const getDestinationMap = async ({ }, })); + // merge outgoing spans with transactions by span.id/parent.id const joinedBySpanId = joinByKey( - [...outgoingConnections, ...joinByKey(incomingConnections, 'service')], - 'id' + [...outgoingConnections, ...incomingConnections], + SPAN_ID ); - const connections = joinByKey( + // we could have multiple connections per address because + // of multiple event outcomes + const dedupedConnectionsByAddress = joinByKey( joinedBySpanId, SPAN_DESTINATION_SERVICE_RESOURCE - ).map((connection) => { - const info = { + ); + + // identify a connection by either service.name, service.environment, agent.name + // OR span.destination.service.resource + + const connectionsWithId = dedupedConnectionsByAddress.map((connection) => { + const id = + 'service' in connection + ? { service: connection.service } + : pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE); + + return { + ...connection, + id, + }; + }); + + const dedupedConnectionsById = joinByKey(connectionsWithId, 'id'); + + const connectionsByAddress = keyBy( + connectionsWithId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // per span.destination.service.resource, return merged/deduped item + return mapValues(connectionsByAddress, ({ id }) => { + const connection = dedupedConnectionsById.find((dedupedConnection) => + isEqual(id, dedupedConnection.id) + )!; + + return { + id, span: { type: connection[SPAN_TYPE], subtype: connection[SPAN_SUBTYPE], @@ -153,10 +187,6 @@ export const getDestinationMap = async ({ }, }, }, - }; - - return { - ...info, ...('service' in connection && connection.service ? { service: { @@ -170,9 +200,4 @@ export const getDestinationMap = async ({ : {}), }; }); - - // map span.destination.service.resource to an instrumented service (service.name, service.environment) - // or an external service (span.type, span.subtype) - - return keyBy(connections, SPAN_DESTINATION_SERVICE_RESOURCE); }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts index 9b75af87c9225..40b8d3e7054c5 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -37,6 +37,7 @@ export const getMetrics = async ({ events: [ProcessorEvent.metric], }, body: { + track_total_hits: true, size: 0, query: { bool: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 1e7701b92b333..0ac881aeac00e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -5,6 +5,8 @@ */ import { ValuesType } from 'utility-types'; import { merge } from 'lodash'; +import { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../common/elasticsearch_fieldnames'; +import { maybe } from '../../../../common/utils/maybe'; import { isFiniteNumber } from '../../../../common/utils/is_finite_number'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { joinByKey } from '../../../../common/utils/join_by_key'; @@ -64,117 +66,129 @@ export async function getServiceDependencies({ }), ]); - const metricsWithMappedDestinations = allMetrics.map((metricItem) => { + const metricsWithDestinationIds = allMetrics.map((metricItem) => { const spanDestination = metricItem.span.destination.service.resource; - const destination = destinationMap[spanDestination]; - - return { - destination: merge({ span: metricItem.span }, destination || {}), - metrics: [metricItem], + const destination = maybe(destinationMap[spanDestination]); + const id = destination?.id || { + [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, }; - }, []); - const metricsJoinedByDestination = joinByKey( - metricsWithMappedDestinations, - 'destination', - (a, b) => ({ - ...a, - ...b, - metrics: [...a.metrics, ...b.metrics], - }) - ); - - const metricsByResolvedAddress = metricsJoinedByDestination.map( - ({ destination, metrics }) => { - const mergedMetrics = metrics.reduce< - Omit, 'span'> - >( - (prev, current) => { - return { - value: { - count: prev.value.count + current.value.count, - latency_sum: prev.value.latency_sum + current.value.latency_sum, - error_count: prev.value.error_count + current.value.error_count, + return merge( + { + id, + metrics: [metricItem], + span: { + destination: { + service: { + resource: spanDestination, }, - timeseries: joinByKey( - [...prev.timeseries, ...current.timeseries], - 'x', - (a, b) => ({ - x: a.x, - count: a.count + b.count, - latency_sum: a.latency_sum + b.latency_sum, - error_count: a.error_count + b.error_count, - }) - ), - }; - }, - { - value: { - count: 0, - latency_sum: 0, - error_count: 0, }, - timeseries: [], - } - ); - - const deltaAsMinutes = (end - start) / 60 / 1000; - - const destMetrics = { - latency: { - value: - mergedMetrics.value.count > 0 - ? mergedMetrics.value.latency_sum / mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? point.latency_sum / point.count : null, - })), }, - throughput: { - value: - mergedMetrics.value.count > 0 - ? mergedMetrics.value.count / deltaAsMinutes - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? point.count / deltaAsMinutes : null, - })), - }, - errorRate: { - value: - mergedMetrics.value.count > 0 - ? (mergedMetrics.value.error_count ?? 0) / - mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? (point.error_count ?? 0) / point.count : null, - })), - }, - }; + }, + destination + ); + }, []); + + const metricsJoinedByDestinationId = joinByKey( + metricsWithDestinationIds, + 'id', + (a, b) => { + const { metrics: metricsA, ...itemA } = a; + const { metrics: metricsB, ...itemB } = b; - if ('service' in destination) { + return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); + } + ); + + const metricsByResolvedAddress = metricsJoinedByDestinationId.map((item) => { + const mergedMetrics = item.metrics.reduce< + Omit, 'span'> + >( + (prev, current) => { return { - name: destination.service.name, - type: 'service' as const, - serviceName: destination.service.name, - environment: destination.service.environment, - agentName: destination.agent.name, - ...destMetrics, + value: { + count: prev.value.count + current.value.count, + latency_sum: prev.value.latency_sum + current.value.latency_sum, + error_count: prev.value.error_count + current.value.error_count, + }, + timeseries: joinByKey( + [...prev.timeseries, ...current.timeseries], + 'x', + (a, b) => ({ + x: a.x, + count: a.count + b.count, + latency_sum: a.latency_sum + b.latency_sum, + error_count: a.error_count + b.error_count, + }) + ), }; + }, + { + value: { + count: 0, + latency_sum: 0, + error_count: 0, + }, + timeseries: [], } + ); + + const deltaAsMinutes = (end - start) / 60 / 1000; + const destMetrics = { + latency: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.latency_sum / mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.latency_sum / point.count : null, + })), + }, + throughput: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.count / deltaAsMinutes + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.count / deltaAsMinutes : null, + })), + }, + errorRate: { + value: + mergedMetrics.value.count > 0 + ? (mergedMetrics.value.error_count ?? 0) / mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? (point.error_count ?? 0) / point.count : null, + })), + }, + }; + + if (item.service) { return { - name: destination.span.destination.service.resource, - type: 'external' as const, - spanType: destination.span.type, - spanSubtype: destination.span.subtype, + name: item.service.name, + type: 'service' as const, + serviceName: item.service.name, + environment: item.service.environment, + // agent.name should always be there, type returned from joinByKey is too pessimistic + agentName: item.agent!.name, ...destMetrics, }; } - ); + + return { + name: item.span.destination.service.resource, + type: 'external' as const, + spanType: item.span.type, + spanSubtype: item.span.subtype, + ...destMetrics, + }; + }); const latencySums = metricsByResolvedAddress .map((metrics) => metrics.latency.value) diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts deleted file mode 100644 index c57f343b8e051..0000000000000 --- a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies.ts +++ /dev/null @@ -1,105 +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. - */ - -import expect from '@kbn/expect'; -import url from 'url'; -import { sortBy } from 'lodash'; -import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; -import { ENVIRONMENT_ALL } from '../../../../../plugins/apm/common/environment_filter_values'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import archives 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 { start, end } = archives[archiveName]; - - describe('Service overview dependencies', () => { - describe('when data is not loaded', () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/dependencies`, - query: { - start, - end, - numBuckets: 20, - environment: ENVIRONMENT_ALL.value, - }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql([]); - }); - }); - - // skip until es archive can be updated - describe.skip('when data is loaded', () => { - let response: { - status: number; - body: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'>; - }; - - before(async () => { - await esArchiver.load(archiveName); - - response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/dependencies`, - query: { - start, - end, - numBuckets: 20, - environment: ENVIRONMENT_ALL.value, - }, - }) - ); - }); - - after(() => esArchiver.unload(archiveName)); - - it('returns a successful response', () => { - expect(response.status).to.be(200); - }); - - it('returns at least one item', () => { - expect(response.body.length).to.be.greaterThan(0); - }); - - it('returns the right names', () => { - const names = response.body.map((item) => item.name); - expectSnapshot(names.sort()).toMatchInline(); - }); - - it('returns the right service names', () => { - const serviceNames = response.body.map((item) => item.serviceName).filter(Boolean); - - expectSnapshot(serviceNames.sort()).toMatchInline(); - }); - - it('returns the right latency values', () => { - const latencyValues = sortBy( - response.body.map((item) => ({ name: item.name, latency: item.latency.value })), - 'name' - ); - - expectSnapshot(latencyValues).toMatchInline(); - }); - - it('returns the right throughput values', () => { - const throughputValues = sortBy( - response.body.map((item) => ({ name: item.name, latency: item.throughput.value })), - 'name' - ); - - expectSnapshot(throughputValues).toMatchInline(); - }); - }); - }); -} diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/es_utils.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/es_utils.ts new file mode 100644 index 0000000000000..85f48d4c260ad --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/es_utils.ts @@ -0,0 +1,216 @@ +/* + * 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 uuid from 'uuid'; + +export function createServiceDependencyDocs({ + time, + service, + agentName, + resource, + responseTime, + outcome, + span, + to, +}: { + time: number; + resource: string; + responseTime: { + count: number; + sum: number; + }; + service: { + name: string; + environment?: string; + }; + agentName: string; + span: { + type: string; + subtype: string; + }; + outcome: 'success' | 'failure' | 'unknown'; + to?: { + service: { + name: string; + environment?: string; + }; + agentName: string; + }; +}) { + const spanId = uuid.v4(); + + return [ + { + processor: { + event: 'metric' as const, + }, + observer: { + version_major: 7, + }, + '@timestamp': new Date(time).toISOString(), + service, + agent: { + name: agentName, + }, + event: { + outcome, + }, + span: { + destination: { + service: { + resource, + response_time: { + sum: { + us: responseTime.sum, + }, + count: responseTime.count, + }, + }, + }, + }, + }, + { + processor: { + event: 'span' as const, + }, + observer: { + version_major: 7, + }, + '@timestamp': new Date(time).toISOString(), + service, + agent: { + name: agentName, + }, + event: { + outcome, + }, + span: { + destination: { + service: { + resource, + }, + }, + id: spanId, + type: span.type, + subtype: span.subtype, + }, + }, + ...(to + ? [ + { + processor: { + event: 'transaction' as const, + }, + observer: { + version_major: 7, + }, + '@timestamp': new Date(time + 1).toISOString(), + event: { + outcome: 'unknown', + }, + parent: { + id: spanId, + }, + service: to.service, + agent: { + name: to.agentName, + }, + }, + ] + : []), + ]; +} + +export const apmDependenciesMapping = { + properties: { + '@timestamp': { + type: 'date', + }, + event: { + dynamic: false, + properties: { + outcome: { + type: 'keyword', + }, + }, + }, + agent: { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + }, + }, + service: { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + environment: { + type: 'keyword', + }, + }, + }, + span: { + dynamic: false, + properties: { + id: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + subtype: { + type: 'keyword', + }, + destination: { + dynamic: false, + properties: { + service: { + dynamic: false, + properties: { + resource: { + type: 'keyword', + }, + response_time: { + properties: { + count: { + type: 'long', + }, + sum: { + properties: { + us: { + type: 'long', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + parent: { + dynamic: false, + properties: { + id: { + type: 'keyword', + }, + }, + }, + processor: { + dynamic: false, + properties: { + event: { + type: 'keyword', + }, + }, + }, + }, +}; diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/index.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/index.ts new file mode 100644 index 0000000000000..3349580f59068 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/dependencies/index.ts @@ -0,0 +1,385 @@ +/* + * 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 url from 'url'; +import { sortBy, pick, last } from 'lodash'; +import { ValuesType } from 'utility-types'; +import { Maybe } from '../../../../../../plugins/apm/typings/common'; +import { isFiniteNumber } from '../../../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { ENVIRONMENT_ALL } from '../../../../../../plugins/apm/common/environment_filter_values'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import archives from '../../../../common/archives_metadata'; +import { apmDependenciesMapping, createServiceDependencyDocs } from './es_utils'; + +const round = (num: Maybe): string => (isFiniteNumber(num) ? num.toPrecision(4) : ''); + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + describe('Service overview dependencies', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/dependencies`, + query: { + start, + end, + numBuckets: 20, + environment: ENVIRONMENT_ALL.value, + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when specific data is loaded', () => { + let response: { + status: number; + body: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'>; + }; + + const indices = { + metric: 'apm-dependencies-metric', + transaction: 'apm-dependencies-transaction', + span: 'apm-dependencies-span', + }; + + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + + after(async () => { + const allIndices = Object.values(indices).join(','); + const indexExists = (await es.indices.exists({ index: allIndices })).body; + if (indexExists) { + await es.indices.delete({ + index: allIndices, + }); + } + }); + + before(async () => { + await es.indices.create({ + index: indices.metric, + body: { + mappings: apmDependenciesMapping, + }, + }); + + await es.indices.create({ + index: indices.transaction, + body: { + mappings: apmDependenciesMapping, + }, + }); + + await es.indices.create({ + index: indices.span, + body: { + mappings: apmDependenciesMapping, + }, + }); + + const docs = [ + ...createServiceDependencyDocs({ + service: { + name: 'opbeans-java', + environment: 'production', + }, + agentName: 'java', + span: { + type: 'external', + subtype: 'http', + }, + resource: 'opbeans-node:3000', + outcome: 'success', + responseTime: { + count: 2, + sum: 10, + }, + time: startTime, + to: { + service: { + name: 'opbeans-node', + }, + agentName: 'nodejs', + }, + }), + ...createServiceDependencyDocs({ + service: { + name: 'opbeans-java', + environment: 'production', + }, + agentName: 'java', + span: { + type: 'external', + subtype: 'http', + }, + resource: 'opbeans-node:3000', + outcome: 'failure', + responseTime: { + count: 1, + sum: 10, + }, + time: startTime, + }), + ...createServiceDependencyDocs({ + service: { + name: 'opbeans-java', + environment: 'production', + }, + agentName: 'java', + span: { + type: 'external', + subtype: 'http', + }, + resource: 'postgres', + outcome: 'success', + responseTime: { + count: 1, + sum: 3, + }, + time: startTime, + }), + ...createServiceDependencyDocs({ + service: { + name: 'opbeans-java', + environment: 'production', + }, + agentName: 'java', + span: { + type: 'external', + subtype: 'http', + }, + resource: 'opbeans-node-via-proxy', + outcome: 'success', + responseTime: { + count: 1, + sum: 1, + }, + time: endTime - 1, + to: { + service: { + name: 'opbeans-node', + }, + agentName: 'nodejs', + }, + }), + ]; + + const bulkActions = docs.reduce( + (prev, doc) => { + return [...prev, { index: { _index: indices[doc.processor.event] } }, doc]; + }, + [] as Array< + | { + index: { + _index: string; + }; + } + | ValuesType + > + ); + + await es.bulk({ + body: bulkActions, + refresh: 'wait_for', + }); + + response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/dependencies`, + query: { + start, + end, + numBuckets: 20, + environment: ENVIRONMENT_ALL.value, + }, + }) + ); + }); + + it('returns a 200', () => { + expect(response.status).to.be(200); + }); + + it('returns two dependencies', () => { + expect(response.body.length).to.be(2); + }); + + it('returns opbeans-node as a dependency', () => { + const opbeansNode = response.body.find( + (item) => item.type === 'service' && item.serviceName === 'opbeans-node' + ); + + expect(opbeansNode !== undefined).to.be(true); + + const values = { + latency: round(opbeansNode?.latency.value), + throughput: round(opbeansNode?.throughput.value), + errorRate: round(opbeansNode?.errorRate.value), + ...pick(opbeansNode, 'serviceName', 'type', 'agentName', 'environment', 'impact'), + }; + + const count = 4; + const sum = 21; + const errors = 1; + + expect(values).to.eql({ + agentName: 'nodejs', + environment: '', + serviceName: 'opbeans-node', + type: 'service', + errorRate: round(errors / count), + latency: round(sum / count), + throughput: round(count / ((endTime - startTime) / 1000 / 60)), + impact: 100, + }); + + const firstValue = round(opbeansNode?.latency.timeseries[0].y); + const lastValue = round(last(opbeansNode?.latency.timeseries)?.y); + + expect(firstValue).to.be(round(20 / 3)); + expect(lastValue).to.be('1.000'); + }); + + it('returns postgres as an external dependency', () => { + const postgres = response.body.find( + (item) => item.type === 'external' && item.name === 'postgres' + ); + + expect(postgres !== undefined).to.be(true); + + const values = { + latency: round(postgres?.latency.value), + throughput: round(postgres?.throughput.value), + errorRate: round(postgres?.errorRate.value), + ...pick(postgres, 'spanType', 'spanSubtype', 'name', 'impact', 'type'), + }; + + const count = 1; + const sum = 3; + const errors = 0; + + expect(values).to.eql({ + spanType: 'external', + spanSubtype: 'http', + name: 'postgres', + type: 'external', + errorRate: round(errors / count), + latency: round(sum / count), + throughput: round(count / ((endTime - startTime) / 1000 / 60)), + impact: 0, + }); + }); + }); + + describe('when data is loaded', () => { + let response: { + status: number; + body: APIReturnType<'GET /api/apm/services/{serviceName}/dependencies'>; + }; + + before(async () => { + await esArchiver.load(archiveName); + + response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/dependencies`, + query: { + start, + end, + numBuckets: 20, + environment: ENVIRONMENT_ALL.value, + }, + }) + ); + }); + + after(() => esArchiver.unload(archiveName)); + + it('returns a successful response', () => { + expect(response.status).to.be(200); + }); + + it('returns at least one item', () => { + expect(response.body.length).to.be.greaterThan(0); + }); + + it('returns the right names', () => { + const names = response.body.map((item) => item.name); + expectSnapshot(names.sort()).toMatchInline(` + Array [ + "opbeans-go", + "postgresql", + ] + `); + }); + + it('returns the right service names', () => { + const serviceNames = response.body + .map((item) => (item.type === 'service' ? item.serviceName : undefined)) + .filter(Boolean); + + expectSnapshot(serviceNames.sort()).toMatchInline(` + Array [ + "opbeans-go", + ] + `); + }); + + it('returns the right latency values', () => { + const latencyValues = sortBy( + response.body.map((item) => ({ name: item.name, latency: item.latency.value })), + 'name' + ); + + expectSnapshot(latencyValues).toMatchInline(` + Array [ + Object { + "latency": 38506.4285714286, + "name": "opbeans-go", + }, + Object { + "latency": 5908.77272727273, + "name": "postgresql", + }, + ] + `); + }); + + it('returns the right throughput values', () => { + const throughputValues = sortBy( + response.body.map((item) => ({ name: item.name, latency: item.throughput.value })), + 'name' + ); + + expectSnapshot(throughputValues).toMatchInline(` + Array [ + Object { + "latency": 0.466666666666667, + "name": "opbeans-go", + }, + Object { + "latency": 3.66666666666667, + "name": "postgresql", + }, + ] + `); + }); + }); + }); +} 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 03d5602d832ed..52c9dd74167f5 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 @@ -5,8 +5,8 @@ */ import expect from '@kbn/expect'; -import { isEmpty, pick } from 'lodash'; -import { PromiseReturnType } from '../../../../../plugins/observability/typings/common'; +import { isEmpty, pick, sortBy } from 'lodash'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import archives_metadata from '../../../common/archives_metadata'; @@ -43,11 +43,18 @@ export default function ApiTest({ getService }: FtrProviderContext) { after(() => esArchiver.unload(archiveName)); describe('and fetching a list of services', () => { - let response: PromiseReturnType; + let response: { + status: number; + body: APIReturnType<'GET /api/apm/services'>; + }; + + let sortedItems: typeof response.body.items; + before(async () => { response = await supertest.get( `/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}` ); + sortedItems = sortBy(response.body.items, 'serviceName'); }); it('the response is successful', () => { @@ -63,16 +70,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns the correct service names', () => { - expectSnapshot(response.body.items.map((item: any) => item.serviceName)).toMatchInline(` + expectSnapshot(sortedItems.map((item) => item.serviceName)).toMatchInline(` Array [ "kibana", - "opbeans-python", - "opbeans-node", - "opbeans-ruby", - "opbeans-go", "kibana-frontend", "opbeans-dotnet", + "opbeans-go", "opbeans-java", + "opbeans-node", + "opbeans-python", + "opbeans-ruby", "opbeans-rum", ] `); @@ -80,7 +87,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns the correct metrics averages', () => { expectSnapshot( - response.body.items.map((item: any) => + sortedItems.map((item) => pick( item, 'transactionErrorRate.value', @@ -103,76 +110,76 @@ export default function ApiTest({ getService }: FtrProviderContext) { }, Object { "avgResponseTime": Object { - "value": 217138.013645224, - }, - "transactionErrorRate": Object { - "value": 0.315789473684211, + "value": 2629229.16666667, }, "transactionsPerMinute": Object { - "value": 17.1, + "value": 3.2, }, }, Object { "avgResponseTime": Object { - "value": 563605.417040359, + "value": 631521.83908046, }, "transactionErrorRate": Object { - "value": 0.0210526315789474, + "value": 0.0229885057471264, }, "transactionsPerMinute": Object { - "value": 7.43333333333333, + "value": 2.9, }, }, Object { "avgResponseTime": Object { - "value": 70518.9328358209, + "value": 27946.1484375, }, "transactionErrorRate": Object { - "value": 0.0373134328358209, + "value": 0.015625, }, "transactionsPerMinute": Object { - "value": 4.46666666666667, + "value": 4.26666666666667, }, }, Object { "avgResponseTime": Object { - "value": 27946.1484375, + "value": 237339.813333333, }, "transactionErrorRate": Object { - "value": 0.015625, + "value": 0.16, }, "transactionsPerMinute": Object { - "value": 4.26666666666667, + "value": 2.5, }, }, Object { "avgResponseTime": Object { - "value": 2629229.16666667, + "value": 563605.417040359, + }, + "transactionErrorRate": Object { + "value": 0.0210526315789474, }, "transactionsPerMinute": Object { - "value": 3.2, + "value": 7.43333333333333, }, }, Object { "avgResponseTime": Object { - "value": 631521.83908046, + "value": 217138.013645224, }, "transactionErrorRate": Object { - "value": 0.0229885057471264, + "value": 0.315789473684211, }, "transactionsPerMinute": Object { - "value": 2.9, + "value": 17.1, }, }, Object { "avgResponseTime": Object { - "value": 237339.813333333, + "value": 70518.9328358209, }, "transactionErrorRate": Object { - "value": 0.16, + "value": 0.0373134328358209, }, "transactionsPerMinute": Object { - "value": 2.5, + "value": 4.46666666666667, }, }, Object { @@ -188,29 +195,28 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns environments', () => { - expectSnapshot(response.body.items.map((item: any) => item.environments ?? [])) - .toMatchInline(` + expectSnapshot(sortedItems.map((item) => item.environments ?? [])).toMatchInline(` Array [ Array [ "production", ], - Array [], Array [ - "testing", + "production", ], - Array [], Array [ - "testing", + "production", ], Array [ - "production", + "testing", ], Array [ "production", ], Array [ - "production", + "testing", ], + Array [], + Array [], Array [ "testing", ], @@ -222,22 +228,18 @@ export default function ApiTest({ getService }: FtrProviderContext) { // 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' - ); + const rumServices = sortedItems.filter((item) => item.agentName === 'rum-js'); expect(rumServices.length).to.be.greaterThan(0); - expect(rumServices.every((item: any) => isEmpty(item.transactionErrorRate?.value))); + expect(rumServices.every((item) => 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' - ); + const nonRumServices = sortedItems.filter((item) => item.agentName !== 'rum-js'); expect( - nonRumServices.every((item: any) => { + nonRumServices.every((item) => { return ( typeof item.transactionErrorRate?.value === 'number' && item.transactionErrorRate.timeseries.length > 0 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 index efd15df7e9c87..92f9a96136f11 100644 --- 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 @@ -5,6 +5,8 @@ */ import expect from '@kbn/expect'; +import { sortBy } from 'lodash'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; import { PromiseReturnType } from '../../../../../plugins/observability/typings/common'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import archives_metadata from '../../../common/archives_metadata'; @@ -31,7 +33,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { describe('with the default APM read user', () => { describe('and fetching a list of services', () => { - let response: PromiseReturnType; + let response: { + status: number; + body: APIReturnType<'GET /api/apm/services'>; + }; + before(async () => { response = await supertest.get( `/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}` @@ -54,7 +60,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { // services report as unknown (so without any health status): // https://github.com/elastic/kibana/issues/77083 - const healthStatuses = response.body.items.map((item: any) => item.healthStatus); + const healthStatuses = sortBy(response.body.items, 'serviceName').map( + (item: any) => item.healthStatus + ); expect(healthStatuses.filter(Boolean).length).to.be.greaterThan(0);