diff --git a/x-pack/plugins/apm/common/utils/formatters/duration.ts b/x-pack/plugins/apm/common/utils/formatters/duration.ts index c0a99e0152fa7..8e563399a0f1f 100644 --- a/x-pack/plugins/apm/common/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.ts @@ -8,9 +8,10 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { memoize } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; -import { asDecimalOrInteger, asInteger } from './formatters'; +import { asDecimal, asDecimalOrInteger, asInteger } from './formatters'; import { TimeUnit } from './datetime'; import { Maybe } from '../../../typings/common'; +import { isFiniteNumber } from '../is_finite_number'; interface FormatterOptions { defaultValue?: string; @@ -99,7 +100,7 @@ function convertTo({ microseconds: Maybe; defaultValue?: string; }): ConvertedDuration { - if (microseconds == null) { + if (!isFiniteNumber(microseconds)) { return { value: defaultValue, formatted: defaultValue }; } @@ -143,6 +144,29 @@ export const getDurationFormatter: TimeFormatterBuilder = memoize( } ); +export function asTransactionRate(value: Maybe) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + let displayedValue: string; + + if (value === 0) { + displayedValue = '0'; + } else if (value <= 0.1) { + displayedValue = '< 0.1'; + } else { + displayedValue = asDecimal(value); + } + + return i18n.translate('xpack.apm.transactionRateLabel', { + defaultMessage: `{value} tpm`, + values: { + value: displayedValue, + }, + }); +} + /** * Converts value and returns it formatted - 00 unit */ @@ -150,7 +174,7 @@ export function asDuration( value: Maybe, { defaultValue = NOT_AVAILABLE_LABEL }: FormatterOptions = {} ) { - if (value == null) { + if (!isFiniteNumber(value)) { return defaultValue; } diff --git a/x-pack/plugins/apm/common/utils/formatters/formatters.ts b/x-pack/plugins/apm/common/utils/formatters/formatters.ts index d84bf86d0de2f..2314e915e3161 100644 --- a/x-pack/plugins/apm/common/utils/formatters/formatters.ts +++ b/x-pack/plugins/apm/common/utils/formatters/formatters.ts @@ -5,6 +5,9 @@ */ import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; +import { Maybe } from '../../../typings/common'; +import { NOT_AVAILABLE_LABEL } from '../../i18n'; +import { isFiniteNumber } from '../is_finite_number'; export function asDecimal(value: number) { return numeral(value).format('0,0.0'); @@ -25,11 +28,11 @@ export function tpmUnit(type?: string) { } export function asPercent( - numerator: number, + numerator: Maybe, denominator: number | undefined, - fallbackResult = '' + fallbackResult = NOT_AVAILABLE_LABEL ) { - if (!denominator || isNaN(numerator)) { + if (!denominator || !isFiniteNumber(numerator)) { return fallbackResult; } diff --git a/x-pack/plugins/apm/common/utils/is_finite_number.ts b/x-pack/plugins/apm/common/utils/is_finite_number.ts new file mode 100644 index 0000000000000..47c4f5fdbd0ee --- /dev/null +++ b/x-pack/plugins/apm/common/utils/is_finite_number.ts @@ -0,0 +1,12 @@ +/* + * 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 { isFinite } from 'lodash'; +import { Maybe } from '../../typings/common'; + +// _.isNumber() returns true for NaN, _.isFinite() does not refine +export function isFiniteNumber(value: Maybe): value is number { + return isFinite(value); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx index 1628a664a6c27..8463da0824bde 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx @@ -65,21 +65,19 @@ export function ServiceStatsList({ title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', { defaultMessage: 'Trans. error rate (avg.)', }), - description: isNumber(avgErrorRate) ? asPercent(avgErrorRate, 1) : null, + description: asPercent(avgErrorRate, 1, ''), }, { title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', { defaultMessage: 'CPU usage (avg.)', }), - description: isNumber(avgCpuUsage) ? asPercent(avgCpuUsage, 1) : null, + description: asPercent(avgCpuUsage, 1, ''), }, { title: i18n.translate('xpack.apm.serviceMap.avgMemoryUsagePopoverStat', { defaultMessage: 'Memory usage (avg.)', }), - description: isNumber(avgMemoryUsage) - ? asPercent(avgMemoryUsage, 1) - : null, + description: asPercent(avgMemoryUsage, 1, ''), }, ]; diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx index 5c9677e3c7af2..89c5c801a5683 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx @@ -128,7 +128,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { }), field: 'cpu', sortable: true, - render: (value: number | null) => asPercent(value || 0, 1), + render: (value: number | null) => asPercent(value, 1), }, { name: i18n.translate('xpack.apm.jvmsTable.heapMemoryColumnLabel', { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx index 716fed7775f7b..77257f5af7c7e 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { useUrlParams } from '../../../../hooks/useUrlParams'; import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; export function ServiceListMetric({ @@ -16,14 +15,8 @@ export function ServiceListMetric({ series?: Array<{ x: number; y: number | null }>; valueLabel: React.ReactNode; }) { - const { - urlParams: { start, end }, - } = useUrlParams(); - return ( > = [ render: (_, { transactionErrorRate }) => { const value = transactionErrorRate?.value; - const valueLabel = - value !== null && value !== undefined ? asPercent(value, 1) : ''; + const valueLabel = asPercent(value, 1); return ( - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableTitle', - { - defaultMessage: 'Transactions', - } - )} -

-
-
- - - {i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableLinkText', - { - defaultMessage: 'View transactions', - } - )} - - -
+
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index 82dbd6dd86aab..b4228878dd9f5 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -21,10 +21,10 @@ import { px, truncate, unit } from '../../../../style/variables'; import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink'; +import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; import { TimestampTooltip } from '../../../shared/TimestampTooltip'; import { ServiceOverviewTable } from '../service_overview_table'; import { TableLinkFlexItem } from '../table_link_flex_item'; -import { FetchWrapper } from './fetch_wrapper'; interface Props { serviceName: string; @@ -135,8 +135,6 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }, } )} - start={parseFloat(start!)} - end={parseFloat(end!)} /> ); }, @@ -225,7 +223,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { - + - + ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx new file mode 100644 index 0000000000000..e91ab338c4a27 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -0,0 +1,319 @@ +/* + * 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 { + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { EuiToolTip } from '@elastic/eui'; +import { ValuesType } from 'utility-types'; +import { + asDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +import { px, truncate, unit } from '../../../../style/variables'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/useFetcher'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { + APIReturnType, + callApmApi, +} from '../../../../services/rest/createCallApmApi'; +import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; +import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; +import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; +import { TableLinkFlexItem } from '../table_link_flex_item'; +import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { ImpactBar } from '../../../shared/ImpactBar'; +import { ServiceOverviewTable } from '../service_overview_table'; + +type ServiceTransactionGroupItem = ValuesType< + APIReturnType< + 'GET /api/apm/services/{serviceName}/overview_transaction_groups' + >['transactionGroups'] +>; + +interface Props { + serviceName: string; +} + +type SortField = 'latency' | 'throughput' | 'errorRate' | 'impact'; +type SortDirection = 'asc' | 'desc'; + +const PAGE_SIZE = 5; +const DEFAULT_SORT = { + direction: 'desc' as const, + field: 'impact' as const, +}; + +const TransactionGroupLinkWrapper = styled.div` + width: 100%; + .euiToolTipAnchor { + width: 100% !important; + } +`; + +const StyledTransactionDetailLink = styled(TransactionDetailLink)` + display: block; + ${truncate('100%')} +`; + +export function ServiceOverviewTransactionsTable(props: Props) { + const { serviceName } = props; + + const { + uiFilters, + urlParams: { start, end }, + } = useUrlParams(); + + const [tableOptions, setTableOptions] = useState<{ + pageIndex: number; + sort: { + direction: SortDirection; + field: SortField; + }; + }>({ + pageIndex: 0, + sort: DEFAULT_SORT, + }); + + const { + data = { + totalItemCount: 0, + items: [], + tableOptions: { + pageIndex: 0, + sort: DEFAULT_SORT, + }, + }, + status, + } = useFetcher(() => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/overview_transaction_groups', + params: { + path: { serviceName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + size: PAGE_SIZE, + numBuckets: 20, + pageIndex: tableOptions.pageIndex, + sortField: tableOptions.sort.field, + sortDirection: tableOptions.sort.direction, + }, + }, + }).then((response) => { + return { + items: response.transactionGroups, + totalItemCount: response.totalTransactionGroups, + tableOptions: { + pageIndex: tableOptions.pageIndex, + sort: { + field: tableOptions.sort.field, + direction: tableOptions.sort.direction, + }, + }, + }; + }); + }, [ + serviceName, + start, + end, + uiFilters, + tableOptions.pageIndex, + tableOptions.sort.field, + tableOptions.sort.direction, + ]); + + const { + items, + totalItemCount, + tableOptions: { pageIndex, sort }, + } = data; + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnName', + { + defaultMessage: 'Name', + } + ), + render: (_, { name, transactionType }) => { + return ( + + + + {name} + + + + ); + }, + }, + { + field: 'latency', + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency', + { + defaultMessage: 'Latency', + } + ), + width: px(unit * 10), + render: (_, { latency }) => { + return ( + + ); + }, + }, + { + field: 'throughput', + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnTroughput', + { + defaultMessage: 'Traffic', + } + ), + width: px(unit * 10), + render: (_, { throughput }) => { + return ( + + ); + }, + }, + { + field: 'error_rate', + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', + { + defaultMessage: 'Error rate', + } + ), + width: px(unit * 8), + render: (_, { errorRate }) => { + return ( + + ); + }, + }, + { + field: 'impact', + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnImpact', + { + defaultMessage: 'Impact', + } + ), + width: px(unit * 5), + render: (_, { impact }) => { + return ; + }, + }, + ]; + + return ( + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableTitle', + { + defaultMessage: 'Transactions', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableLinkText', + { + defaultMessage: 'View transactions', + } + )} + + +
+
+ + + + { + setTableOptions({ + pageIndex: newTableOptions.page?.index ?? 0, + sort: newTableOptions.sort + ? { + field: newTableOptions.sort.field as SortField, + direction: newTableOptions.sort.direction, + } + : DEFAULT_SORT, + }); + }} + sorting={{ + enableAllColumns: true, + sort: { + direction: sort.direction, + field: sort.field, + }, + }} + /> + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx b/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx index ed931191cfb96..f5d71ad15f1ce 100644 --- a/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx @@ -10,11 +10,23 @@ import React from 'react'; // TODO: extend from EUI's EuiProgress prop interface export interface ImpactBarProps extends Record { value: number; + size?: 'l' | 'm'; max?: number; } -export function ImpactBar({ value, max = 100, ...rest }: ImpactBarProps) { +export function ImpactBar({ + value, + size = 'l', + max = 100, + ...rest +}: ImpactBarProps) { return ( - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 18b914afea995..d96f3cd698aed 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 @@ -15,16 +15,15 @@ import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; interface Props { color: string; - series: Array<{ x: number; y: number | null }>; + series?: Array<{ x: number; y: number | null }>; + width: string; } export function SparkPlot(props: Props) { - const { series, color } = props; + const { series, color, width } = props; const chartTheme = useChartTheme(); - const isEmpty = series.every((point) => point.y === null); - - if (isEmpty) { + if (!series || series.every((point) => point.y === null)) { return ( @@ -40,7 +39,7 @@ export function SparkPlot(props: Props) { } return ( - + ; valueLabel: React.ReactNode; + compact?: boolean; }) { const theme = useTheme(); @@ -45,7 +43,8 @@ export function SparkPlotWithValueLabel({ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx b/x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx similarity index 74% rename from x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx rename to x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx index 912490d866e88..e8d62cd8bd85b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/fetch_wrapper.tsx +++ b/x-pack/plugins/apm/public/components/shared/table_fetch_wrapper/index.tsx @@ -5,10 +5,10 @@ */ import React, { ReactNode } from 'react'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ErrorStatePrompt } from '../../../shared/ErrorStatePrompt'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { ErrorStatePrompt } from '../ErrorStatePrompt'; -export function FetchWrapper({ +export function TableFetchWrapper({ status, children, }: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts new file mode 100644 index 0000000000000..73b91429f5101 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts @@ -0,0 +1,134 @@ +/* + * 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 { PromiseReturnType } from '../../../../../observability/typings/common'; +import { EventOutcome } from '../../../../common/event_outcome'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; + +import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../../helpers/aggregated_transactions'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { getBucketSize } from '../../helpers/get_bucket_size'; + +export type TransactionGroupTimeseriesData = PromiseReturnType< + typeof getTimeseriesDataForTransactionGroups +>; + +export async function getTimeseriesDataForTransactionGroups({ + apmEventClient, + start, + end, + serviceName, + transactionNames, + esFilter, + searchAggregatedTransactions, + size, + numBuckets, +}: { + apmEventClient: APMEventClient; + start: number; + end: number; + serviceName: string; + transactionNames: string[]; + esFilter: ESFilter[]; + searchAggregatedTransactions: boolean; + size: number; + numBuckets: number; +}) { + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const timeseriesResponse = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { [TRANSACTION_NAME]: transactionNames } }, + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ], + }, + }, + aggs: { + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + size, + }, + aggs: { + transaction_types: { + terms: { + field: TRANSACTION_TYPE, + }, + }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + avg_latency: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + transaction_count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + [EVENT_OUTCOME]: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, + }, + aggs: { + transaction_count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return timeseriesResponse.aggregations?.transaction_groups.buckets ?? []; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts new file mode 100644 index 0000000000000..5d3d7014ba8f8 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts @@ -0,0 +1,168 @@ +/* + * 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 { orderBy } from 'lodash'; +import { ValuesType } from 'utility-types'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; +import { EventOutcome } from '../../../../common/event_outcome'; +import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + TRANSACTION_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../../helpers/aggregated_transactions'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; + +export type ServiceOverviewTransactionGroupSortField = + | 'latency' + | 'throughput' + | 'errorRate' + | 'impact'; + +export type TransactionGroupWithoutTimeseriesData = ValuesType< + PromiseReturnType['transactionGroups'] +>; + +export async function getTransactionGroupsForPage({ + apmEventClient, + searchAggregatedTransactions, + serviceName, + start, + end, + esFilter, + sortField, + sortDirection, + pageIndex, + size, +}: { + apmEventClient: APMEventClient; + searchAggregatedTransactions: boolean; + serviceName: string; + start: number; + end: number; + esFilter: ESFilter[]; + sortField: ServiceOverviewTransactionGroupSortField; + sortDirection: 'asc' | 'desc'; + pageIndex: number; + size: number; +}) { + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ], + }, + }, + aggs: { + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + size: 500, + order: { + _count: 'desc', + }, + }, + aggs: { + avg_latency: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + transaction_count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + [EVENT_OUTCOME]: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, + }, + aggs: { + transaction_count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const transactionGroups = + response.aggregations?.transaction_groups.buckets.map((bucket) => { + const errorRate = + bucket.transaction_count.value > 0 + ? (bucket[EVENT_OUTCOME].transaction_count.value ?? 0) / + bucket.transaction_count.value + : null; + + return { + name: bucket.key as string, + latency: bucket.avg_latency.value, + throughput: bucket.transaction_count.value, + errorRate, + }; + }) ?? []; + + const totalDurationValues = transactionGroups.map( + (group) => (group.latency ?? 0) * group.throughput + ); + + const minTotalDuration = Math.min(...totalDurationValues); + const maxTotalDuration = Math.max(...totalDurationValues); + + const transactionGroupsWithImpact = transactionGroups.map((group) => ({ + ...group, + impact: + (((group.latency ?? 0) * group.throughput - minTotalDuration) / + (maxTotalDuration - minTotalDuration)) * + 100, + })); + + // Sort transaction groups first, and only get timeseries for data in view. + // This is to limit the possibility of creating too many buckets. + + const sortedAndSlicedTransactionGroups = orderBy( + transactionGroupsWithImpact, + sortField, + [sortDirection] + ).slice(pageIndex * size, pageIndex * size + size); + + return { + transactionGroups: sortedAndSlicedTransactionGroups, + totalTransactionGroups: transactionGroups.length, + isAggregationAccurate: + (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === + 0, + }; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts new file mode 100644 index 0000000000000..88fd189712e07 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts @@ -0,0 +1,77 @@ +/* + * 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 { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getTimeseriesDataForTransactionGroups } from './get_timeseries_data_for_transaction_groups'; +import { + getTransactionGroupsForPage, + ServiceOverviewTransactionGroupSortField, +} from './get_transaction_groups_for_page'; +import { mergeTransactionGroupData } from './merge_transaction_group_data'; + +export async function getServiceTransactionGroups({ + serviceName, + setup, + size, + numBuckets, + pageIndex, + sortDirection, + sortField, + searchAggregatedTransactions, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + size: number; + pageIndex: number; + numBuckets: number; + sortDirection: 'asc' | 'desc'; + sortField: ServiceOverviewTransactionGroupSortField; + searchAggregatedTransactions: boolean; +}) { + const { apmEventClient, start, end, esFilter } = setup; + + const { + transactionGroups, + totalTransactionGroups, + isAggregationAccurate, + } = await getTransactionGroupsForPage({ + apmEventClient, + start, + end, + serviceName, + esFilter, + pageIndex, + sortField, + sortDirection, + size, + searchAggregatedTransactions, + }); + + const transactionNames = transactionGroups.map((group) => group.name); + + const timeseriesData = await getTimeseriesDataForTransactionGroups({ + apmEventClient, + start, + end, + esFilter, + numBuckets, + searchAggregatedTransactions, + serviceName, + size, + transactionNames, + }); + + return { + transactionGroups: mergeTransactionGroupData({ + transactionGroups, + timeseriesData, + start, + end, + }), + totalTransactionGroups, + isAggregationAccurate, + }; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts new file mode 100644 index 0000000000000..f9266baddaf27 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts @@ -0,0 +1,98 @@ +/* + * 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 { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; + +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, +} from '../../../../common/transaction_types'; + +import { TransactionGroupTimeseriesData } from './get_timeseries_data_for_transaction_groups'; + +import { TransactionGroupWithoutTimeseriesData } from './get_transaction_groups_for_page'; + +export function mergeTransactionGroupData({ + start, + end, + transactionGroups, + timeseriesData, +}: { + start: number; + end: number; + transactionGroups: TransactionGroupWithoutTimeseriesData[]; + timeseriesData: TransactionGroupTimeseriesData; +}) { + const deltaAsMinutes = (end - start) / 1000 / 60; + + return transactionGroups.map((transactionGroup) => { + const groupBucket = timeseriesData.find( + ({ key }) => key === transactionGroup.name + ); + + const transactionTypes = + groupBucket?.transaction_types.buckets.map( + (bucket) => bucket.key as string + ) ?? []; + + const transactionType = + transactionTypes.find( + (type) => type === TRANSACTION_PAGE_LOAD || type === TRANSACTION_REQUEST + ) ?? transactionTypes[0]; + + const timeseriesBuckets = groupBucket?.timeseries.buckets ?? []; + + return timeseriesBuckets.reduce( + (prev, point) => { + return { + ...prev, + latency: { + ...prev.latency, + timeseries: prev.latency.timeseries.concat({ + x: point.key, + y: point.avg_latency.value, + }), + }, + throughput: { + ...prev.throughput, + timeseries: prev.throughput.timeseries.concat({ + x: point.key, + y: point.transaction_count.value / deltaAsMinutes, + }), + }, + errorRate: { + ...prev.errorRate, + timeseries: prev.errorRate.timeseries.concat({ + x: point.key, + y: + point.transaction_count.value > 0 + ? (point[EVENT_OUTCOME].transaction_count.value ?? 0) / + point.transaction_count.value + : null, + }), + }, + }; + }, + { + name: transactionGroup.name, + transactionType, + latency: { + value: transactionGroup.latency, + timeseries: [] as Array<{ x: number; y: number | null }>, + }, + throughput: { + value: transactionGroup.throughput, + timeseries: [] as Array<{ x: number; y: number }>, + }, + errorRate: { + value: transactionGroup.errorRate, + timeseries: [] as Array<{ x: number; y: number | null }>, + }, + impact: transactionGroup.impact, + } + ); + }); +} 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 1edbae474d1c8..019482dd44485 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -22,6 +22,7 @@ import { serviceAnnotationsRoute, serviceAnnotationsCreateRoute, serviceErrorGroupsRoute, + serviceTransactionGroupsRoute, } from './services'; import { agentConfigurationRoute, @@ -116,6 +117,7 @@ const createApmApi = () => { .add(serviceAnnotationsRoute) .add(serviceAnnotationsCreateRoute) .add(serviceErrorGroupsRoute) + .add(serviceTransactionGroupsRoute) // 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 e091e470b24b2..5e02fad2155ad 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -19,6 +19,7 @@ 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 { toNumberRt } from '../../common/runtime_types/to_number_rt'; +import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', @@ -233,7 +234,6 @@ export const serviceErrorGroupsRoute = createRoute({ path: { serviceName }, query: { size, numBuckets, pageIndex, sortDirection, sortField }, } = context.params; - return getServiceErrorGroups({ serviceName, setup, @@ -245,3 +245,52 @@ export const serviceErrorGroupsRoute = createRoute({ }); }, }); + +export const serviceTransactionGroupsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/overview_transaction_groups', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.type({ + size: toNumberRt, + numBuckets: toNumberRt, + pageIndex: toNumberRt, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + sortField: t.union([ + t.literal('latency'), + t.literal('throughput'), + t.literal('errorRate'), + t.literal('impact'), + ]), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + const { + path: { serviceName }, + query: { size, numBuckets, pageIndex, sortDirection, sortField }, + } = context.params; + + return getServiceTransactionGroups({ + setup, + serviceName, + pageIndex, + searchAggregatedTransactions, + size, + sortDirection, + sortField, + numBuckets, + }); + }, +}); 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 a8e3f2832ec4e..e9bc59df96108 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -24,6 +24,7 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont describe('Service overview', function () { loadTestFile(require.resolve('./service_overview/error_groups')); + loadTestFile(require.resolve('./service_overview/transaction_groups')); }); describe('Settings', function () { diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/transaction_groups.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/transaction_groups.ts new file mode 100644 index 0000000000000..f9ae8cc9a1976 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/transaction_groups.ts @@ -0,0 +1,255 @@ +/* + * 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 { pick, uniqBy } from 'lodash'; +import url from 'url'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import archives from '../../../common/archives_metadata'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + describe('Service overview transaction groups', () => { + 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/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size: 5, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql({ + totalTransactionGroups: 0, + transactionGroups: [], + isAggregationAccurate: true, + }); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size: 5, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body.totalTransactionGroups).toMatchInline(`12`); + + expectSnapshot(response.body.transactionGroups.map((group: any) => group.name)) + .toMatchInline(` + Array [ + "DispatcherServlet#doGet", + "APIRestController#stats", + "APIRestController#topProducts", + "APIRestController#order", + "APIRestController#customer", + ] + `); + + expectSnapshot(response.body.transactionGroups.map((group: any) => group.impact)) + .toMatchInline(` + Array [ + 100, + 0.794579770440557, + 0.298214689777379, + 0.290932594821871, + 0.270655974123907, + ] + `); + + const firstItem = response.body.transactionGroups[0]; + + expectSnapshot( + pick(firstItem, 'name', 'latency.value', 'throughput.value', 'errorRate.value', 'impact') + ).toMatchInline(` + Object { + "errorRate": Object { + "value": 0.107142857142857, + }, + "impact": 100, + "latency": Object { + "value": 996636.214285714, + }, + "name": "DispatcherServlet#doGet", + "throughput": Object { + "value": 28, + }, + } + `); + + expectSnapshot( + firstItem.latency.timeseries.filter(({ y }: any) => y > 0).length + ).toMatchInline(`15`); + + expectSnapshot( + firstItem.throughput.timeseries.filter(({ y }: any) => y > 0).length + ).toMatchInline(`15`); + + expectSnapshot( + firstItem.errorRate.timeseries.filter(({ y }: any) => y > 0).length + ).toMatchInline(`3`); + }); + + it('sorts items in the correct order', async () => { + const descendingResponse = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size: 5, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + expect(descendingResponse.status).to.be(200); + + const descendingOccurrences = descendingResponse.body.transactionGroups.map( + (item: any) => item.impact + ); + + expect(descendingOccurrences).to.eql(descendingOccurrences.concat().sort().reverse()); + + const ascendingResponse = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size: 5, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + const ascendingOccurrences = ascendingResponse.body.transactionGroups.map( + (item: any) => item.impact + ); + + expect(ascendingOccurrences).to.eql(ascendingOccurrences.concat().sort().reverse()); + }); + + it('sorts items by the correct field', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size: 5, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'latency', + }, + }) + ); + + expect(response.status).to.be(200); + + const latencies = response.body.transactionGroups.map((group: any) => group.latency.value); + + expect(latencies).to.eql(latencies.concat().sort().reverse()); + }); + + it('paginates through the items', async () => { + const size = 1; + + const firstPage = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/overview_transaction_groups`, + query: { + start, + end, + uiFilters: '{}', + size, + numBuckets: 20, + pageIndex: 0, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + expect(firstPage.status).to.eql(200); + + const totalItems = firstPage.body.totalTransactionGroups; + + const pages = Math.floor(totalItems / size); + + const items = await new Array(pages) + .fill(undefined) + .reduce(async (prevItemsPromise, _, pageIndex) => { + const prevItems = await prevItemsPromise; + + const thisPage = await supertest.get( + url.format({ + pathname: '/api/apm/services/opbeans-java/overview_transaction_groups', + query: { + start, + end, + uiFilters: '{}', + size, + numBuckets: 20, + pageIndex, + sortDirection: 'desc', + sortField: 'impact', + }, + }) + ); + + return prevItems.concat(thisPage.body.transactionGroups); + }, Promise.resolve([])); + + expect(items.length).to.eql(totalItems); + + expect(uniqBy(items, 'name').length).to.eql(totalItems); + }); + }); + }); +}