diff --git a/x-pack/plugins/apm/common/connections.ts b/x-pack/plugins/apm/common/connections.ts index aa3863374337c..1fde50ce5489e 100644 --- a/x-pack/plugins/apm/common/connections.ts +++ b/x-pack/plugins/apm/common/connections.ts @@ -47,6 +47,10 @@ export interface ConnectionStatsItem { value: number | null; timeseries: Coordinate[]; }; + totalTime: { + value: number | null; + timeseries: Coordinate[]; + }; }; } diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index e4b3d48992efa..c89526b332084 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -51,11 +51,11 @@ export function BackendInventoryDependenciesTable() { return callApmApi({ endpoint: 'GET /api/apm/backends/top_backends', params: { - query: { start, end, environment, numBuckets: 20, offset }, + query: { start, end, environment, numBuckets: 20, offset, kuery }, }, }); }, - [start, end, environment, offset] + [start, end, environment, offset, kuery] ); const dependencies = diff --git a/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx b/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx index b89586b42fd6b..1488c4773359c 100644 --- a/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx @@ -4,8 +4,35 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { EuiSpacer } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React from 'react'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { ServiceOverviewDependenciesTable } from '../service_overview/service_overview_dependencies_table'; +import { ServiceDependenciesBreakdownChart } from './service_dependencies_breakdown_chart'; export function ServiceDependencies() { - return <>; + return ( + <> + + + +

+ {i18n.translate( + 'xpack.apm.serviceDependencies.breakdownChartTitle', + { + defaultMessage: 'Time spent by dependency', + } + )} +

+
+ +
+
+ + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_dependencies/service_dependencies_breakdown_chart.tsx b/x-pack/plugins/apm/public/components/app/service_dependencies/service_dependencies_breakdown_chart.tsx new file mode 100644 index 0000000000000..a33b0db7c4baf --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_dependencies/service_dependencies_breakdown_chart.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { getVizColorForIndex } from '../../../../common/viz_colors'; +import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { BreakdownChart } from '../../shared/charts/breakdown_chart'; + +export function ServiceDependenciesBreakdownChart({ + height, +}: { + height: number; +}) { + const { start, end } = useTimeRange(); + const { serviceName } = useApmServiceContext(); + + const { + query: { kuery, environment }, + } = useApmParams('/services/:serviceName/dependencies'); + + const { data, status } = useFetcher( + (callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/dependencies/breakdown', + params: { + path: { + serviceName, + }, + query: { + start, + end, + kuery, + environment, + }, + }, + }); + }, + [serviceName, start, end, kuery, environment] + ); + + const timeseries: Array> = + data?.breakdown.map((item, index) => { + return { + title: item.title, + data: item.data, + type: 'area', + color: getVizColorForIndex(index), + }; + }) ?? []; + + return ( + + ); +} 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 08d554b9a54e8..d9df9acf9ff65 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 @@ -34,7 +34,7 @@ export function ServiceOverviewDependenciesTable() { const { query, query: { kuery, rangeFrom, rangeTo }, - } = useApmParams('/services/:serviceName/overview'); + } = useApmParams('/services/:serviceName/*'); const { offset } = getTimeRangeComparison({ start, diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx similarity index 88% rename from x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx rename to x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx index 4fdfbff7917d1..f39c39113fedc 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx @@ -16,19 +16,21 @@ import { Position, ScaleType, Settings, + TickFormatter, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React from 'react'; import { useHistory } from 'react-router-dom'; +import { Annotation } from '../../../../../common/annotations'; import { useChartTheme } from '../../../../../../observability/public'; import { asAbsoluteDateTime, + asDuration, asPercent, } from '../../../../../common/utils/formatters'; import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; -import { useAnnotationsContext } from '../../../../context/annotations/use_annotations_context'; import { useChartPointerEventContext } from '../../../../context/chart_pointer_event/use_chart_pointer_event_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; @@ -41,17 +43,23 @@ interface Props { fetchStatus: FETCH_STATUS; height?: number; showAnnotations: boolean; + annotations: Annotation[]; timeseries?: Array>; + yAxisType: 'duration' | 'percentage'; } -export function TransactionBreakdownChartContents({ +const asPercentBound = (y: number | null) => asPercent(y, 1); +const asDurationBound = (y: number | null) => asDuration(y); + +export function BreakdownChart({ fetchStatus, height = unit * 16, showAnnotations, + annotations, timeseries, + yAxisType, }: Props) { const history = useHistory(); - const { annotations } = useAnnotationsContext(); const chartTheme = useChartTheme(); const { chartRef, setPointerEvent } = useChartPointerEventContext(); @@ -68,6 +76,9 @@ export function TransactionBreakdownChartContents({ const isEmpty = isTimeseriesEmpty(timeseries); + const yTickFormat: TickFormatter = + yAxisType === 'duration' ? asDurationBound : asPercentBound; + return ( @@ -98,7 +109,7 @@ export function TransactionBreakdownChartContents({ id="y-axis" ticks={3} position={Position.Left} - tickFormat={(y: number) => asPercent(y ?? 0, 1)} + tickFormat={yTickFormat} /> {showAnnotations && ( @@ -133,7 +144,9 @@ export function TransactionBreakdownChartContents({ yAccessors={['y']} data={serie.data} stackAccessors={['x']} - stackMode={'percentage'} + stackMode={ + yAxisType === 'percentage' ? 'percentage' : undefined + } color={serie.areaColor} curve={CurveType.CURVE_MONOTONE_X} /> diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx index 40c5e39589fb1..0e2b1e185f9d9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx @@ -8,8 +8,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useAnnotationsContext } from '../../../../context/annotations/use_annotations_context'; import { useTransactionBreakdown } from './use_transaction_breakdown'; -import { TransactionBreakdownChartContents } from './transaction_breakdown_chart_contents'; +import { BreakdownChart } from '../breakdown_chart'; export function TransactionBreakdownChart({ height, @@ -19,6 +20,7 @@ export function TransactionBreakdownChart({ showAnnotations?: boolean; }) { const { data, status } = useTransactionBreakdown(); + const { annotations } = useAnnotationsContext(); const { timeseries } = data; return ( @@ -34,11 +36,13 @@ export function TransactionBreakdownChart({ - diff --git a/x-pack/plugins/apm/server/lib/backends/get_throughput_charts_for_backend.ts b/x-pack/plugins/apm/server/lib/backends/get_throughput_charts_for_backend.ts index 4fbd521ea4443..ae8a6db903d8a 100644 --- a/x-pack/plugins/apm/server/lib/backends/get_throughput_charts_for_backend.ts +++ b/x-pack/plugins/apm/server/lib/backends/get_throughput_charts_for_backend.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../common/elasticsearch_fieldnames'; +import { + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, +} from '../../../common/elasticsearch_fieldnames'; import { environmentQuery } from '../../../common/utils/environment_query'; import { kqlQuery, rangeQuery } from '../../../../observability/server'; import { ProcessorEvent } from '../../../common/processor_event'; @@ -63,7 +66,10 @@ export async function getThroughputChartsForBackend({ }), aggs: { throughput: { - rate: {}, + rate: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + unit: 'minute', + }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/backends/get_top_backends.ts b/x-pack/plugins/apm/server/lib/backends/get_top_backends.ts index 2f9af0bafd37c..9b361774b8a9f 100644 --- a/x-pack/plugins/apm/server/lib/backends/get_top_backends.ts +++ b/x-pack/plugins/apm/server/lib/backends/get_top_backends.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { kqlQuery } from '../../../../observability/server'; +import { NodeType } from '../../../common/connections'; import { environmentQuery } from '../../../common/utils/environment_query'; import { getConnectionStats } from '../connections/get_connection_stats'; import { getConnectionStatsItemsWithRelativeImpact } from '../connections/get_connection_stats/get_connection_stats_items_with_relative_impact'; -import { NodeType } from '../../../common/connections'; import { Setup } from '../helpers/setup_request'; export async function getTopBackends({ @@ -18,6 +19,7 @@ export async function getTopBackends({ numBuckets, environment, offset, + kuery, }: { setup: Setup; start: number; @@ -25,13 +27,14 @@ export async function getTopBackends({ numBuckets: number; environment?: string; offset?: string; + kuery?: string; }) { const statsItems = await getConnectionStats({ setup, start, end, numBuckets, - filter: [...environmentQuery(environment)], + filter: [...environmentQuery(environment), ...kqlQuery(kuery)], offset, collapseBy: 'downstream', }); diff --git a/x-pack/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts b/x-pack/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts index 94a23f6218e5d..4f48f3388c017 100644 --- a/x-pack/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts +++ b/x-pack/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts @@ -119,6 +119,16 @@ export const getStats = async ({ }, }, }, + total_latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, + }, + total_latency_count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, timeseries: { date_histogram: { field: '@timestamp', @@ -126,6 +136,7 @@ export const getStats = async ({ start: startWithOffset, end: endWithOffset, numBuckets, + minBucketSize: 60, }).intervalString, extended_bounds: { min: startWithOffset, diff --git a/x-pack/plugins/apm/server/lib/connections/get_connection_stats/index.ts b/x-pack/plugins/apm/server/lib/connections/get_connection_stats/index.ts index 37fcd9877327b..03b94defda6dd 100644 --- a/x-pack/plugins/apm/server/lib/connections/get_connection_stats/index.ts +++ b/x-pack/plugins/apm/server/lib/connections/get_connection_stats/index.ts @@ -113,6 +113,13 @@ export function getConnectionStats({ y: point.count > 0 ? point.latency_sum / point.count : null, })), }, + totalTime: { + value: mergedStats.value.latency_sum, + timeseries: mergedStats.timeseries.map((point) => ({ + x: point.x, + y: point.latency_sum, + })), + }, throughput: { value: mergedStats.value.count > 0 diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies_breakdown.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies_breakdown.ts new file mode 100644 index 0000000000000..8ebf6b7e017d4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies_breakdown.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { sortBy, take } from 'lodash'; +import { getNodeName } from '../../../common/connections'; +import { kqlQuery } from '../../../../observability/server'; +import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { Setup } from '../helpers/setup_request'; +import { getConnectionStats } from '../connections/get_connection_stats'; + +export async function getServiceDependenciesBreakdown({ + setup, + start, + end, + serviceName, + environment, + kuery, +}: { + setup: Setup; + start: number; + end: number; + serviceName: string; + environment?: string; + kuery?: string; +}) { + const items = await getConnectionStats({ + setup, + start, + end, + numBuckets: 100, + collapseBy: 'downstream', + filter: [ + ...environmentQuery(environment), + ...kqlQuery(kuery), + { term: { [SERVICE_NAME]: serviceName } }, + ], + }); + + return take( + sortBy(items, (item) => item.stats.totalTime ?? 0).reverse(), + 20 + ).map((item) => { + const { stats, location } = item; + + return { + title: getNodeName(location), + data: stats.totalTime.timeseries, + }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/backends.ts b/x-pack/plugins/apm/server/routes/backends.ts index c738e9aa64007..e673770dbbc1a 100644 --- a/x-pack/plugins/apm/server/routes/backends.ts +++ b/x-pack/plugins/apm/server/routes/backends.ts @@ -25,7 +25,7 @@ const topBackendsRoute = createApmServerRoute({ query: t.intersection([rangeRt, t.type({ numBuckets: toNumberRt })]), }), t.partial({ - query: t.intersection([environmentRt, offsetRt]), + query: t.intersection([environmentRt, offsetRt, kueryRt]), }), ]), options: { @@ -35,9 +35,9 @@ const topBackendsRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { start, end } = setup; - const { environment, offset, numBuckets } = resources.params.query; + const { environment, offset, numBuckets, kuery } = resources.params.query; - const opts = { setup, start, end, numBuckets, environment }; + const opts = { setup, start, end, numBuckets, environment, kuery }; const [currentBackends, previousBackends] = await Promise.all([ getTopBackends(opts), diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 87eac9374fd02..f5156fe85fbf5 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -42,6 +42,7 @@ import { } from './default_api_types'; import { offsetPreviousPeriodCoordinates } from '../../common/utils/offset_previous_period_coordinate'; import { getServicesDetailedStatistics } from '../lib/services/get_services_detailed_statistics'; +import { getServiceDependenciesBreakdown } from '../lib/services/get_service_dependencies_breakdown'; const servicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services', @@ -692,6 +693,38 @@ export const serviceDependenciesRoute = createApmServerRoute({ }, }); +export const serviceDependenciesBreakdownRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/dependencies/breakdown', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([environmentRt, rangeRt, kueryRt]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, start, end, kuery } = params.query; + + const breakdown = await getServiceDependenciesBreakdown({ + setup, + start, + end, + serviceName, + environment, + kuery, + }); + + return { + breakdown, + }; + }, +}); + const serviceProfilingTimelineRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', params: t.type({ @@ -822,6 +855,7 @@ export const serviceRouteRepository = createApmServerRouteRepository() .add(serviceInstancesMainStatisticsRoute) .add(serviceInstancesDetailedStatisticsRoute) .add(serviceDependenciesRoute) + .add(serviceDependenciesBreakdownRoute) .add(serviceProfilingTimelineRoute) .add(serviceProfilingStatisticsRoute) .add(serviceAlertsRoute); diff --git a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts index 4f10ee6ff6bd8..942378477f04c 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts @@ -357,6 +357,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { 'errorRate.timeseries', 'throughput.timeseries', 'latency.timeseries', + 'totalTime.timeseries', ]) ).toMatchInline(` Object { @@ -370,6 +371,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { "throughput": Object { "value": 1.83333333333333, }, + "totalTime": Object { + "value": 61439716, + }, } `); }); @@ -467,8 +471,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { response.body.serviceDependencies.map((item) => ({ name: getName(item.location), impact: item.currentStats.impact, - latency: item.currentStats.latency.value, - throughput: item.currentStats.throughput.value, })), 'name' ); @@ -477,27 +479,50 @@ export default function ApiTest({ getService }: FtrProviderContext) { Array [ Object { "impact": 0, - "latency": 9496.32291666667, "name": "elasticsearch", - "throughput": 3.2, }, Object { "impact": 100, - "latency": 1117085.74545455, "name": "opbeans-dotnet", - "throughput": 1.83333333333333, }, Object { "impact": 71.0403531954737, - "latency": 27826.9968314322, "name": "postgresql", - "throughput": 52.6, }, Object { "impact": 1.41447268043525, - "latency": 1468.27242524917, "name": "redis", - "throughput": 40.1333333333333, + }, + ] + `); + }); + + it('returns the right totalTime values', () => { + const totalTimeValues = sortBy( + response.body.serviceDependencies.map((item) => ({ + name: getName(item.location), + totalTime: item.currentStats.totalTime.value, + })), + 'name' + ); + + expectSnapshot(totalTimeValues).toMatchInline(` + Array [ + Object { + "name": "elasticsearch", + "totalTime": 911647, + }, + Object { + "name": "opbeans-dotnet", + "totalTime": 61439716, + }, + Object { + "name": "postgresql", + "totalTime": 43911001, + }, + Object { + "name": "redis", + "totalTime": 1767800, }, ] `);