diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 8c233d3691c7f..c15fb7110c473 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -118,6 +118,10 @@ exports[`Error SPAN_ACTION 1`] = `undefined`; exports[`Error SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; +exports[`Error SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT 1`] = `undefined`; + +exports[`Error SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM 1`] = `undefined`; + exports[`Error SPAN_DURATION 1`] = `undefined`; exports[`Error SPAN_ID 1`] = `undefined`; @@ -290,6 +294,10 @@ exports[`Span SPAN_ACTION 1`] = `"my action"`; exports[`Span SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; +exports[`Span SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT 1`] = `undefined`; + +exports[`Span SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM 1`] = `undefined`; + exports[`Span SPAN_DURATION 1`] = `1337`; exports[`Span SPAN_ID 1`] = `"span id"`; @@ -462,6 +470,10 @@ exports[`Transaction SPAN_ACTION 1`] = `undefined`; exports[`Transaction SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; +exports[`Transaction SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT 1`] = `undefined`; + +exports[`Transaction SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM 1`] = `undefined`; + exports[`Transaction SPAN_DURATION 1`] = `undefined`; exports[`Transaction SPAN_ID 1`] = `undefined`; 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 8e563399a0f1f..cf2d99fe5119d 100644 --- a/x-pack/plugins/apm/common/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { memoize } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; -import { asDecimal, asDecimalOrInteger, asInteger } from './formatters'; +import { asDecimalOrInteger, asInteger, asDecimal } from './formatters'; import { TimeUnit } from './datetime'; import { Maybe } from '../../../typings/common'; import { isFiniteNumber } from '../is_finite_number'; @@ -181,7 +181,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.test.ts b/x-pack/plugins/apm/common/utils/join_by_key/index.test.ts index 458d21bfea58f..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 @@ -101,4 +101,65 @@ 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']); + }); + + 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 b49f536400514..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 } 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 @@ -23,24 +23,48 @@ import { isEqual } from 'lodash'; */ type JoinedReturnType< + T extends Record, + U extends UnionToIntersection +> = Array< + Partial & + { + [k in keyof T]: T[k]; + } +>; + +type ArrayOrSingle = T | T[]; + +export function joinByKey< T extends Record, U extends UnionToIntersection, - V extends keyof T & keyof U -> = Array & Record>; + V extends ArrayOrSingle +>(items: T[], key: V): JoinedReturnType; 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) => { - let item = prev.find((prevItem) => isEqual(prevItem[key], current[key])); + 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 | string[], + mergeFn: Function = (a: Record, b: Record) => + merge({}, a, b) +) { + const keys = castArray(key); + return items.reduce>>((prev, current) => { + let item = prev.find((prevItem) => + keys.every((k) => isEqual(prevItem[k], current[k])) + ); if (!item) { - item = { ...current } as ValuesType>; + item = { ...current }; prev.push(item); } else { - Object.assign(item, current); + pull(prev, item).push(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 f2f51496fcca8..9a0ebb7173c26 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/use_fetcher'; -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 dcb407d27e690..1f6a9276b5d27 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,12 +18,11 @@ import { isRumAgentName } from '../../../../common/agent_name'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; -import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; +import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart'; import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table'; -import { TableLinkFlexItem } from './table_link_flex_item'; /** * The height a chart should be if it's next to a table with 5 rows and a title. @@ -98,30 +97,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.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 5b05497b482ce..3db857ad32190 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -15,12 +15,13 @@ import { } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; -import * as useFetcherHooks from '../../../hooks/use_fetcher'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import * as useAnnotationsHooks from '../../../context/annotations/use_annotations_context'; import * as useTransactionBreakdownHooks from '../../shared/charts/transaction_breakdown_chart/use_transaction_breakdown'; import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; +import { waitFor } from '@testing-library/dom'; +import * as callApmApi from '../../../services/rest/createCallApmApi'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -54,7 +55,7 @@ function Wrapper({ children }: { children?: ReactNode }) { } describe('ServiceOverview', () => { - it('renders', () => { + it('renders', async () => { jest .spyOn(useAnnotationsHooks, 'useAnnotationsContext') .mockReturnValue({ annotations: [] }); @@ -64,18 +65,29 @@ describe('ServiceOverview', () => { indexPattern: undefined, status: FETCH_STATUS.SUCCESS, }); - jest.spyOn(useFetcherHooks, 'useFetcher').mockReturnValue({ - data: { - items: [], - tableOptions: { - pageIndex: 0, - sort: { direction: 'desc', field: 'test field' }, - }, - totalItemCount: 0, - throughput: [], + + const calls = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GET /api/apm/services/{serviceName}/error_groups': { + error_groups: [], + total_error_groups: 0, + }, + 'GET /api/apm/services/{serviceName}/transactions/groups/overview': { + transactionGroups: [], + totalTransactionGroups: 0, + isAggregationAccurate: true, }, - refetch: () => {}, - status: FETCH_STATUS.SUCCESS, + 'GET /api/apm/services/{serviceName}/dependencies': [], + }; + + jest.spyOn(callApmApi, 'createCallApmApi').mockImplementation(() => {}); + + jest.spyOn(callApmApi, 'callApmApi').mockImplementation(({ endpoint }) => { + const response = calls[endpoint as keyof typeof calls]; + + return response + ? Promise.resolve(response) + : Promise.reject(`Response for ${endpoint} is not defined`); }); jest .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') @@ -85,10 +97,19 @@ describe('ServiceOverview', () => { status: FETCH_STATUS.SUCCESS, }); - expect(() => - renderWithTheme(, { + const { findAllByText } = renderWithTheme( + , + { wrapper: Wrapper, - }) - ).not.toThrowError(); + } + ); + + await waitFor(() => + expect(callApmApi.callApmApi).toHaveBeenCalledTimes( + Object.keys(calls).length + ) + ); + + expect((await findAllByText('Latency')).length).toBeGreaterThan(0); }); }); 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..87ff702e0a960 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -0,0 +1,249 @@ +/* + * 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 { + 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 { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +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'; +import { ServiceOverviewTableContainer } from '../service_overview_table'; + +interface Props { + serviceName: string; +} + +export function ServiceOverviewDependenciesTable({ serviceName }: Props) { + const columns: Array> = [ + { + field: 'name', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnBackend', + { + defaultMessage: 'Backend', + } + ), + render: (_, item) => { + return ( + + + {item.type === 'service' ? ( + + ) : ( + + )} + + + {item.type === 'service' ? ( + + {item.name} + + ) : ( + item.name + )} + + + } + /> + ); + }, + sortable: true, + }, + { + field: 'latencyValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnLatency', + { + defaultMessage: 'Latency', + } + ), + width: px(unit * 10), + render: (_, { latency }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'throughputValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnThroughput', + { + defaultMessage: 'Traffic', + } + ), + width: px(unit * 10), + render: (_, { throughput }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'errorRateValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnErrorRate', + { + defaultMessage: 'Error rate', + } + ), + width: px(unit * 10), + render: (_, { errorRate }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'impactValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableColumnImpact', + { + defaultMessage: 'Impact', + } + ), + width: px(unit * 5), + render: (_, { impact }) => { + return ; + }, + sortable: true, + }, + ]; + + const { + urlParams: { start, end, environment }, + } = useUrlParams(); + + const { data = [], status } = useFetcher(() => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/dependencies', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment: environment || ENVIRONMENT_ALL.value, + numBuckets: 20, + }, + }, + }); + }, [start, end, serviceName, environment]); + + // need top-level sortable fields for the managed table + const items = data.map((item) => ({ + ...item, + errorRateValue: item.errorRate.value, + latencyValue: item.latency.value, + throughputValue: item.throughput.value, + impactValue: item.impact, + })); + + return ( + + + + + +

+ {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_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/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 73a819af2d624..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,25 +3,23 @@ * 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; - series?: Array<{ x: number; y: number | null }>; + series?: Array<{ x: number; y: number | null }> | null; width: string; } @@ -46,7 +44,18 @@ export function SparkPlot(props: Props) { return ( - + ; + series?: Array<{ x: number; y: number | null }> | null; valueLabel: React.ReactNode; compact?: boolean; }) { 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..d1062d1044ead --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/span_icon/get_span_icon.ts @@ -0,0 +1,74 @@ +/* + * 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 { maybe } from '../../../../common/utils/maybe'; +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 defaultIcon; + } + + const types = maybe(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/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..04c4e893577f9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/truncate_with_tooltip/index.tsx @@ -0,0 +1,45 @@ +/* + * 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 tooltipAnchorClassname = '_apm_truncate_tooltip_anchor_'; + +const TooltipWrapper = styled.div` + width: 100%; + .${tooltipAnchorClassname} { + 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/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index 623abf6930297..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'; @@ -28,34 +29,31 @@ 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: rangeFilter(setup.start, 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..d6198e2d3b65a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -0,0 +1,203 @@ +/* + * 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 { isEqual, keyBy, mapValues } from 'lodash'; +import { pickKeys } from '../../../../common/utils/pick_keys'; +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 { joinByKey } from '../../../../common/utils/join_by_key'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; + +export const getDestinationMap = async ({ + setup, + serviceName, + environment, +}: { + setup: Setup & SetupTimeRange; + serviceName: string; + environment: string; +}) => { + const { start, end, apmEventClient } = setup; + + 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 }, + }, + }, + // make sure we get samples for both successful + // and failed calls + { [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] + ), + [SPAN_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[SPAN_ID] + ), + }, + }, + { range: rangeFilter(start, end) }, + ], + }, + }, + size: outgoingConnections.length, + docvalue_fields: [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + AGENT_NAME, + PARENT_ID, + ] as const, + _source: false, + }, + }); + + const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ + [SPAN_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, + }, + })); + + // merge outgoing spans with transactions by span.id/parent.id + const joinedBySpanId = joinByKey( + [...outgoingConnections, ...incomingConnections], + SPAN_ID + ); + + // we could have multiple connections per address because + // of multiple event outcomes + const dedupedConnectionsByAddress = joinByKey( + joinedBySpanId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // 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], + destination: { + service: { + resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], + }, + }, + }, + ...('service' in connection && connection.service + ? { + service: { + name: connection.service.name, + environment: connection.service.environment, + }, + agent: { + name: connection.service.agentName, + }, + } + : {}), + }; + }); +}; 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..40b8d3e7054c5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -0,0 +1,140 @@ +/* + * 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 { getBucketSize } from '../../helpers/get_bucket_size'; +import { EventOutcome } from '../../../../common/event_outcome'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; + +export const getMetrics = async ({ + setup, + serviceName, + environment, + numBuckets, +}: { + setup: Setup & SetupTimeRange; + serviceName: string; + environment: string; + numBuckets: number; +}) => { + const { start, end, apmEventClient } = setup; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + track_total_hits: true, + 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) => ({ + span: { + destination: { + service: { + resource: String(bucket.key), + }, + }, + }, + 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 new file mode 100644 index 0000000000000..0ac881aeac00e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -0,0 +1,209 @@ +/* + * 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 { 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'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getMetrics } from './get_metrics'; +import { getDestinationMap } from './get_destination_map'; + +export type ServiceDependencyItem = { + name: string; + latency: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + throughput: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + errorRate: { + value: number | null; + timeseries: Array<{ x: number; y: number | null }>; + }; + impact: number; +} & ( + | { + type: 'service'; + serviceName: string; + agentName: AgentName; + environment?: string; + } + | { type: 'external'; spanType?: string; spanSubtype?: string } +); + +export async function getServiceDependencies({ + setup, + serviceName, + environment, + numBuckets, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment: string; + numBuckets: number; +}): Promise { + const { start, end } = setup; + + const [allMetrics, destinationMap] = await Promise.all([ + getMetrics({ + setup, + serviceName, + environment, + numBuckets, + }), + getDestinationMap({ + setup, + serviceName, + environment, + }), + ]); + + const metricsWithDestinationIds = allMetrics.map((metricItem) => { + const spanDestination = metricItem.span.destination.service.resource; + + const destination = maybe(destinationMap[spanDestination]); + const id = destination?.id || { + [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, + }; + + return merge( + { + id, + metrics: [metricItem], + span: { + destination: { + service: { + resource: spanDestination, + }, + }, + }, + }, + destination + ); + }, []); + + const metricsJoinedByDestinationId = joinByKey( + metricsWithDestinationIds, + 'id', + (a, b) => { + const { metrics: metricsA, ...itemA } = a; + const { metrics: metricsB, ...itemB } = b; + + return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); + } + ); + + const metricsByResolvedAddress = metricsJoinedByDestinationId.map((item) => { + const mergedMetrics = item.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, + }, + 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: 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) + .filter(isFiniteNumber); + + 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 0e066a1959c49..3d30ba273ed76 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -23,6 +23,7 @@ import { serviceAnnotationsCreateRoute, serviceErrorGroupsRoute, serviceThroughputRoute, + serviceDependenciesRoute, } from './services'; import { agentConfigurationRoute, @@ -121,6 +122,7 @@ const createApmApi = () => { .add(serviceAnnotationsCreateRoute) .add(serviceErrorGroupsRoute) .add(serviceThroughputRoute) + .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 a82f1b64d5537..40ad7fdd05248 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'; import { getThroughput } from '../lib/services/get_throughput'; @@ -275,3 +276,32 @@ export const serviceThroughputRoute = createRoute({ }); }, }); + +export const serviceDependenciesRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/dependencies', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + t.type({ environment: t.string, numBuckets: toNumberRt }), + ]), + }), + options: { + tags: ['access:apm'], + }, + 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; 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 f6ee79382dd07..c5b8320231c0c 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 // TODO: we should not have a service overview. describe('Service overview', function () { loadTestFile(require.resolve('./service_overview/error_groups')); + loadTestFile(require.resolve('./service_overview/dependencies')); }); describe('Settings', function () { 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); 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 f2c51f601a099..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,15 +18,21 @@ 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: { name: string; size?: number; sort?: SortOptions; - _source?: { - includes: string[]; - }; + _source?: + | string + | string[] + | { + includes?: string | string[]; + excludes?: string | string[]; + }; collapse?: { field: string; }; @@ -34,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; @@ -41,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 & { @@ -52,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, @@ -64,7 +117,7 @@ export type ESSearchResponse< aggregations?: AggregationResponseMap; } : {}) & { - hits: Omit['hits'], 'total'> & + hits: Omit['hits'], 'total' | 'hits'> & (TOptions['restTotalHitsAsInt'] extends true ? { total: number; @@ -74,7 +127,7 @@ export type ESSearchResponse< value: number; relation: 'eq' | 'gte'; }; - }); + }) & { hits: ESHitsOf }; }; export interface ESFilter {