diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index ef0a5f2df0434..290e71a23bbb8 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; +import uuid from 'uuid'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; @@ -16,26 +17,44 @@ import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; import { SearchBar } from '../../shared/search_bar'; +import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { NoServicesMessage } from './no_services_message'; import { ServiceList } from './service_list'; import { MLCallout } from './service_list/MLCallout'; const initialData = { - items: [], - hasHistoricalData: true, - hasLegacyData: false, + requestId: '', + mainStatisticsData: { + items: [], + hasHistoricalData: true, + hasLegacyData: false, + }, }; let hasDisplayedToast = false; function useServicesFetcher() { const { - urlParams: { environment, kuery, start, end }, + urlParams: { + environment, + kuery, + start, + end, + comparisonEnabled, + comparisonType, + }, } = useUrlParams(); const { core } = useApmPluginContext(); const upgradeAssistantHref = useUpgradeAssistantHref(); - const { data = initialData, status } = useFetcher( + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonEnabled, + comparisonType, + }); + + const { data = initialData, status: mainStatisticsStatus } = useFetcher( (callApmApi) => { if (start && end) { return callApmApi({ @@ -48,14 +67,50 @@ function useServicesFetcher() { end, }, }, + }).then((mainStatisticsData) => { + return { + requestId: uuid(), + mainStatisticsData, + }; }); } }, [environment, kuery, start, end] ); + const { mainStatisticsData, requestId } = data; + + const { data: comparisonData, status: comparisonStatus } = useFetcher( + (callApmApi) => { + if (start && end && mainStatisticsData.items.length) { + return callApmApi({ + endpoint: 'GET /api/apm/services/detailed_statistics', + params: { + query: { + environment, + kuery, + start, + end, + serviceNames: JSON.stringify( + mainStatisticsData.items + .map(({ serviceName }) => serviceName) + // Service name is sorted to guarantee the same order every time this API is called so the result can be cached. + .sort() + ), + offset, + }, + }, + }); + } + }, + // only fetches detailed statistics when requestId is invalidated by main statistics api call or offset is changed + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId, offset], + { preservePreviousData: false } + ); + useEffect(() => { - if (data.hasLegacyData && !hasDisplayedToast) { + if (mainStatisticsData.hasLegacyData && !hasDisplayedToast) { hasDisplayedToast = true; core.notifications.toasts.addWarning({ @@ -82,14 +137,30 @@ function useServicesFetcher() { ), }); } - }, [data.hasLegacyData, upgradeAssistantHref, core.notifications.toasts]); + }, [ + mainStatisticsData.hasLegacyData, + upgradeAssistantHref, + core.notifications.toasts, + ]); - return { servicesData: data, servicesStatus: status }; + return { + servicesData: mainStatisticsData, + servicesStatus: mainStatisticsStatus, + comparisonData, + isLoading: + mainStatisticsStatus === FETCH_STATUS.LOADING || + comparisonStatus === FETCH_STATUS.LOADING, + }; } export function ServiceInventory() { const { core } = useApmPluginContext(); - const { servicesData, servicesStatus } = useServicesFetcher(); + const { + servicesData, + servicesStatus, + comparisonData, + isLoading, + } = useServicesFetcher(); const { anomalyDetectionJobsData, @@ -111,7 +182,7 @@ export function ServiceInventory() { return ( <> - + {displayMlCallout && ( @@ -120,12 +191,16 @@ export function ServiceInventory() { )} + !isLoading && ( + + ) } /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/no_services_message.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/no_services_message.tsx index 7ca56419f22bd..a2dc5feec44f8 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/no_services_message.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/no_services_message.tsx @@ -8,11 +8,10 @@ import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; -import { LoadingStatePrompt } from '../../shared/LoadingStatePrompt'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { ErrorStatePrompt } from '../../shared/ErrorStatePrompt'; import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; +import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; interface Props { // any data submitted from APM agents found (not just in the given time range) @@ -23,10 +22,6 @@ interface Props { export function NoServicesMessage({ historicalDataFound, status }: Props) { const upgradeAssistantHref = useUpgradeAssistantHref(); - if (status === 'loading') { - return ; - } - if (status === 'failure') { return ; } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/ServiceListMetric.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/ServiceListMetric.tsx index 6ffc6f3f9448e..af2cde7f861cc 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/ServiceListMetric.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/ServiceListMetric.tsx @@ -6,16 +6,26 @@ */ import React from 'react'; +import { Coordinate } from '../../../../../typings/timeseries'; import { SparkPlot } from '../../../shared/charts/spark_plot'; export function ServiceListMetric({ color, series, valueLabel, + comparisonSeries, }: { color: 'euiColorVis1' | 'euiColorVis0' | 'euiColorVis7'; - series?: Array<{ x: number; y: number | null }>; + series?: Coordinate[]; + comparisonSeries?: Coordinate[]; valueLabel: React.ReactNode; }) { - return ; + return ( + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/__fixtures__/service_api_mock_data.ts b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/__fixtures__/service_api_mock_data.ts index e6ad70fc035d8..0f5edb5a4c9ce 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/__fixtures__/service_api_mock_data.ts +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/__fixtures__/service_api_mock_data.ts @@ -14,18 +14,18 @@ export const items: ServiceListAPIResponse['items'] = [ serviceName: 'opbeans-node', transactionType: 'request', agentName: 'nodejs', - transactionsPerMinute: { value: 0, timeseries: [] }, - transactionErrorRate: { value: 46.06666666666667, timeseries: [] }, - avgResponseTime: { value: null, timeseries: [] }, + throughput: 0, + transactionErrorRate: 46.06666666666667, + latency: null, environments: ['test'], }, { serviceName: 'opbeans-python', transactionType: 'page-load', agentName: 'python', - transactionsPerMinute: { value: 86.93333333333334, timeseries: [] }, - transactionErrorRate: { value: 12.6, timeseries: [] }, - avgResponseTime: { value: 91535.42944785276, timeseries: [] }, + throughput: 86.93333333333334, + transactionErrorRate: 12.6, + latency: 91535.42944785276, environments: [], }, ]; diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 1e945aca7c916..c2ba67356fb6b 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -39,11 +39,8 @@ import { ServiceListMetric } from './ServiceListMetric'; type ServiceListAPIResponse = APIReturnType<'GET /api/apm/services'>; type Items = ServiceListAPIResponse['items']; +type ServicesDetailedStatisticsAPIResponse = APIReturnType<'GET /api/apm/services/detailed_statistics'>; -interface Props { - items: Items; - noItemsMessage?: React.ReactNode; -} type ServiceListItem = ValuesType; function formatString(value?: string | null) { @@ -67,9 +64,11 @@ const SERVICE_HEALTH_STATUS_ORDER = [ export function getServiceColumns({ query, showTransactionTypeColumn, + comparisonData, }: { query: Record; showTransactionTypeColumn: boolean; + comparisonData?: ServicesDetailedStatisticsAPIResponse; }): Array> { return [ { @@ -136,34 +135,40 @@ export function getServiceColumns({ ] : []), { - field: 'avgResponseTime', + field: 'latency', name: i18n.translate('xpack.apm.servicesTable.latencyAvgColumnLabel', { defaultMessage: 'Latency (avg.)', }), sortable: true, dataType: 'number', - render: (_, { avgResponseTime }) => ( + render: (_, { serviceName, latency }) => ( ), align: 'left', width: `${unit * 10}px`, }, { - field: 'transactionsPerMinute', + field: 'throughput', name: i18n.translate('xpack.apm.servicesTable.throughputColumnLabel', { defaultMessage: 'Throughput', }), sortable: true, dataType: 'number', - render: (_, { transactionsPerMinute }) => ( + render: (_, { serviceName, throughput }) => ( ), align: 'left', @@ -176,14 +181,16 @@ export function getServiceColumns({ }), sortable: true, dataType: 'number', - render: (_, { transactionErrorRate }) => { - const value = transactionErrorRate?.value; - - const valueLabel = asPercent(value, 1); - + render: (_, { serviceName, transactionErrorRate }) => { + const valueLabel = asPercent(transactionErrorRate, 1); return ( @@ -195,7 +202,19 @@ export function getServiceColumns({ ]; } -export function ServiceList({ items, noItemsMessage }: Props) { +interface Props { + items: Items; + comparisonData?: ServicesDetailedStatisticsAPIResponse; + noItemsMessage?: React.ReactNode; + isLoading: boolean; +} + +export function ServiceList({ + items, + noItemsMessage, + comparisonData, + isLoading, +}: Props) { const displayHealthStatus = items.some((item) => 'healthStatus' in item); const showTransactionTypeColumn = items.some( @@ -207,8 +226,9 @@ export function ServiceList({ items, noItemsMessage }: Props) { const { query } = useApmParams('/services'); const serviceColumns = useMemo( - () => getServiceColumns({ query, showTransactionTypeColumn }), - [query, showTransactionTypeColumn] + () => + getServiceColumns({ query, showTransactionTypeColumn, comparisonData }), + [query, showTransactionTypeColumn, comparisonData] ); const columns = displayHealthStatus @@ -253,6 +273,7 @@ export function ServiceList({ items, noItemsMessage }: Props) { item.transactionsPerMinute?.value ?? 0, + (item) => item.throughput ?? 0, ], [sortDirection, sortDirection] ) @@ -281,12 +302,12 @@ export function ServiceList({ items, noItemsMessage }: Props) { // Use `?? -1` here so `undefined` will appear after/before `0`. // In the table this will make the "N/A" items always at the // bottom/top. - case 'avgResponseTime': - return item.avgResponseTime?.value ?? -1; - case 'transactionsPerMinute': - return item.transactionsPerMinute?.value ?? -1; + case 'latency': + return item.latency ?? -1; + case 'throughput': + return item.throughput ?? -1; case 'transactionErrorRate': - return item.transactionErrorRate?.value ?? -1; + return item.transactionErrorRate ?? -1; default: return item[sortField as keyof typeof item]; } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx index c83f9995caf2e..70a6191a1d6b4 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx @@ -28,13 +28,17 @@ describe('ServiceList', () => { it('renders empty state', () => { expect(() => - renderWithTheme(, { wrapper: Wrapper }) + renderWithTheme(, { + wrapper: Wrapper, + }) ).not.toThrowError(); }); it('renders with data', () => { expect(() => - renderWithTheme(, { wrapper: Wrapper }) + renderWithTheme(, { + wrapper: Wrapper, + }) ).not.toThrowError(); }); @@ -70,18 +74,24 @@ describe('ServiceList', () => { describe('without ML data', () => { it('does not render the health column', () => { - const { queryByText } = renderWithTheme(, { - wrapper: Wrapper, - }); + const { queryByText } = renderWithTheme( + , + { + wrapper: Wrapper, + } + ); const healthHeading = queryByText('Health'); expect(healthHeading).toBeNull(); }); it('sorts by throughput', async () => { - const { findByTitle } = renderWithTheme(, { - wrapper: Wrapper, - }); + const { findByTitle } = renderWithTheme( + , + { + wrapper: Wrapper, + } + ); expect(await findByTitle('Throughput')).toBeInTheDocument(); }); @@ -91,6 +101,7 @@ describe('ServiceList', () => { it('renders the health column', async () => { const { findByTitle } = renderWithTheme( ({ ...item, healthStatus: ServiceHealthStatus.warning, diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap index 55e0bc0fe2431..1e01c00543949 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap @@ -33,6 +33,7 @@ exports[`ManagedTable should render a page-full of items, with defaults 1`] = ` }, ] } + loading={false} noItemsMessage="No items found" onChange={[Function]} pagination={ @@ -81,6 +82,7 @@ exports[`ManagedTable should render when specifying initial values 1`] = ` }, ] } + loading={false} noItemsMessage="No items found" onChange={[Function]} pagination={ diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 3d1527a473740..98f8fb4162267 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -40,6 +40,7 @@ interface Props { sortDirection: 'asc' | 'desc' ) => T[]; pagination?: boolean; + isLoading?: boolean; } function defaultSortFn( @@ -64,6 +65,7 @@ function UnoptimizedManagedTable(props: Props) { sortItems = true, sortFn = defaultSortFn, pagination = true, + isLoading = false, } = props; const { @@ -125,6 +127,7 @@ function UnoptimizedManagedTable(props: Props) { return ( >} // EuiBasicTableColumn is stricter than ITableColumn diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 2f653e2c4df1d..be664529abab4 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -142,33 +142,6 @@ Array [ }, }, }, - "timeseries": Object { - "aggs": Object { - "avg_duration": Object { - "avg": Object { - "field": "transaction.duration.us", - }, - }, - "outcomes": Object { - "terms": Object { - "field": "event.outcome", - "include": Array [ - "failure", - "success", - ], - }, - }, - }, - "date_histogram": Object { - "extended_bounds": Object { - "max": 1528977600000, - "min": 1528113600000, - }, - "field": "@timestamp", - "fixed_interval": "43200s", - "min_doc_count": 0, - }, - }, }, "terms": Object { "field": "transaction.type", diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index c2121dbba97ef..edc9e5cf90026 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { kqlQuery, rangeQuery } from '../../../../../observability/server'; import { AGENT_NAME, SERVICE_ENVIRONMENT, @@ -15,7 +16,6 @@ import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; -import { kqlQuery, rangeQuery } from '../../../../../observability/server'; import { environmentQuery } from '../../../../common/utils/environment_query'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { @@ -24,7 +24,6 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { calculateThroughput } from '../../helpers/calculate_throughput'; -import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; import { calculateTransactionErrorPercentage, getOutcomeAggregation, @@ -111,20 +110,6 @@ export async function getServiceTransactionStats({ }, }, }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSizeForAggregatedTransactions({ - start, - end, - numBuckets: 20, - searchAggregatedTransactions, - }).intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: metrics, - }, }, }, }, @@ -151,43 +136,15 @@ export async function getServiceTransactionStats({ agentName: topTransactionTypeBucket.sample.top[0].metrics[ AGENT_NAME ] as AgentName, - avgResponseTime: { - value: topTransactionTypeBucket.avg_duration.value, - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg_duration.value, - }) - ), - }, - transactionErrorRate: { - value: calculateTransactionErrorPercentage( - topTransactionTypeBucket.outcomes - ), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateTransactionErrorPercentage(dateBucket.outcomes), - }) - ), - }, - transactionsPerMinute: { - value: calculateThroughput({ - start, - end, - value: topTransactionTypeBucket.doc_count, - }), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateThroughput({ - start, - end, - value: dateBucket.doc_count, - }), - }) - ), - }, + latency: topTransactionTypeBucket.avg_duration.value, + transactionErrorRate: calculateTransactionErrorPercentage( + topTransactionTypeBucket.outcomes + ), + throughput: calculateThroughput({ + start, + end, + value: topTransactionTypeBucket.doc_count, + }), }; }) ?? [] ); diff --git a/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/get_service_transaction_detailed_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/get_service_transaction_detailed_statistics.ts new file mode 100644 index 0000000000000..d339641069eb5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/get_service_transaction_detailed_statistics.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { keyBy } from 'lodash'; +import { kqlQuery, rangeQuery } from '../../../../../observability/server'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, +} from '../../../../common/transaction_types'; +import { environmentQuery } from '../../../../common/utils/environment_query'; +import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../../helpers/aggregated_transactions'; +import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { + calculateTransactionErrorPercentage, + getOutcomeAggregation, +} from '../../helpers/transaction_error_rate'; + +export async function getServiceTransactionDetailedStatistics({ + serviceNames, + environment, + kuery, + setup, + searchAggregatedTransactions, + offset, +}: { + serviceNames: string[]; + environment?: string; + kuery?: string; + setup: Setup & SetupTimeRange; + searchAggregatedTransactions: boolean; + offset?: string; +}) { + const { apmEventClient, start, end } = setup; + const { offsetInMs, startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); + + const outcomes = getOutcomeAggregation(); + + const metrics = { + avg_duration: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + outcomes, + }; + + const response = await apmEventClient.search( + 'get_service_transaction_stats', + { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(startWithOffset, endWithOffset), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + include: serviceNames, + size: serviceNames.length, + }, + aggs: { + transactionType: { + terms: { + field: TRANSACTION_TYPE, + }, + aggs: { + ...metrics, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSizeForAggregatedTransactions({ + start: startWithOffset, + end: endWithOffset, + numBuckets: 20, + searchAggregatedTransactions, + }).intervalString, + min_doc_count: 0, + extended_bounds: { + min: startWithOffset, + max: endWithOffset, + }, + }, + aggs: metrics, + }, + }, + }, + }, + }, + }, + }, + } + ); + + return keyBy( + response.aggregations?.services.buckets.map((bucket) => { + const topTransactionTypeBucket = + bucket.transactionType.buckets.find( + ({ key }) => + key === TRANSACTION_REQUEST || key === TRANSACTION_PAGE_LOAD + ) ?? bucket.transactionType.buckets[0]; + + return { + serviceName: bucket.key as string, + latency: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key + offsetInMs, + y: dateBucket.avg_duration.value, + }) + ), + transactionErrorRate: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key + offsetInMs, + y: calculateTransactionErrorPercentage(dateBucket.outcomes), + }) + ), + throughput: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key + offsetInMs, + y: calculateThroughput({ + start, + end, + value: dateBucket.doc_count, + }), + }) + ), + }; + }) ?? [], + 'serviceName' + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/index.ts b/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/index.ts new file mode 100644 index 0000000000000..d4ce90c79f4a6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_services_detailed_statistics/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { withApmSpan } from '../../../utils/with_apm_span'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getServiceTransactionDetailedStatistics } from './get_service_transaction_detailed_statistics'; + +export async function getServicesDetailedStatistics({ + serviceNames, + environment, + kuery, + setup, + searchAggregatedTransactions, + offset, +}: { + serviceNames: string[]; + environment?: string; + kuery?: string; + setup: Setup & SetupTimeRange; + searchAggregatedTransactions: boolean; + offset?: string; +}) { + return withApmSpan('get_service_detailed_statistics', async () => { + const commonProps = { + serviceNames, + environment, + kuery, + setup, + searchAggregatedTransactions, + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getServiceTransactionDetailedStatistics(commonProps), + offset + ? getServiceTransactionDetailedStatistics({ ...commonProps, offset }) + : {}, + ]); + + return { currentPeriod, previousPeriod }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 460aa13feea2b..87eac9374fd02 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -41,6 +41,7 @@ import { rangeRt, } from './default_api_types'; import { offsetPreviousPeriodCoordinates } from '../../common/utils/offset_previous_period_coordinate'; +import { getServicesDetailedStatistics } from '../lib/services/get_services_detailed_statistics'; const servicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services', @@ -67,6 +68,42 @@ const servicesRoute = createApmServerRoute({ }, }); +const servicesDetailedStatisticsRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/detailed_statistics', + params: t.type({ + query: t.intersection([ + environmentRt, + kueryRt, + rangeRt, + offsetRt, + t.type({ serviceNames: jsonRt.pipe(t.array(t.string)) }), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { environment, kuery, offset, serviceNames } = params.query; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + + if (!serviceNames.length) { + throw Boom.badRequest(`serviceNames cannot be empty`); + } + + return getServicesDetailedStatistics({ + environment, + kuery, + setup, + searchAggregatedTransactions, + offset, + serviceNames, + }); + }, +}); + const serviceMetadataDetailsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', params: t.type({ @@ -770,6 +807,7 @@ const serviceAlertsRoute = createApmServerRoute({ export const serviceRouteRepository = createApmServerRouteRepository() .add(servicesRoute) + .add(servicesDetailedStatisticsRoute) .add(serviceMetadataDetailsRoute) .add(serviceMetadataIconsRoute) .add(serviceAgentNameRoute) diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index 4b102402f6f73..12b21ad17bf2f 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -119,6 +119,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./services/error_groups_detailed_statistics')); }); + describe('services/detailed_statistics', function () { + loadTestFile(require.resolve('./services/services_detailed_statistics')); + }); + // Settinges describe('settings/anomaly_detection/basic', function () { loadTestFile(require.resolve('./settings/anomaly_detection/basic')); diff --git a/x-pack/test/apm_api_integration/tests/services/services_detailed_statistics.ts b/x-pack/test/apm_api_integration/tests/services/services_detailed_statistics.ts new file mode 100644 index 0000000000000..eac0bef7c5de0 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/services_detailed_statistics.ts @@ -0,0 +1,225 @@ +/* + * 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 expect from '@kbn/expect'; +import url from 'url'; +import moment from 'moment'; +import { registry } from '../../common/registry'; +import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; + +type ServicesDetailedStatisticsReturn = APIReturnType<'GET /api/apm/services/detailed_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const metadata = archives_metadata[archiveName]; + const { start, end } = metadata; + const serviceNames = ['opbeans-java', 'opbeans-go']; + + registry.when( + 'Services detailed statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { start, end, serviceNames: JSON.stringify(serviceNames), offset: '1d' }, + }) + ); + expect(response.status).to.be(200); + expect(response.body.currentPeriod).to.be.empty(); + expect(response.body.previousPeriod).to.be.empty(); + }); + } + ); + + registry.when( + 'Services detailed statistics when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + let servicesDetailedStatistics: ServicesDetailedStatisticsReturn; + before(async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { start, end, serviceNames: JSON.stringify(serviceNames) }, + }) + ); + expect(response.status).to.be(200); + servicesDetailedStatistics = response.body; + }); + it('returns current period data', async () => { + expect(servicesDetailedStatistics.currentPeriod).not.to.be.empty(); + }); + it("doesn't returns previous period data", async () => { + expect(servicesDetailedStatistics.previousPeriod).to.be.empty(); + }); + it('returns current data for requested service names', () => { + serviceNames.forEach((serviceName) => { + expect(servicesDetailedStatistics.currentPeriod[serviceName]).not.to.be.empty(); + }); + }); + it('returns correct statistics', () => { + const statistics = servicesDetailedStatistics.currentPeriod[serviceNames[0]]; + + expect(statistics.latency.length).to.be.greaterThan(0); + expect(statistics.throughput.length).to.be.greaterThan(0); + expect(statistics.transactionErrorRate.length).to.be.greaterThan(0); + + // latency + const nonNullLantencyDataPoints = statistics.latency.filter(({ y }) => isFiniteNumber(y)); + expect(nonNullLantencyDataPoints.length).to.be.greaterThan(0); + + // throughput + const nonNullThroughputDataPoints = statistics.throughput.filter(({ y }) => + isFiniteNumber(y) + ); + expect(nonNullThroughputDataPoints.length).to.be.greaterThan(0); + + // transaction erro rate + const nonNullTransactionErrorRateDataPoints = statistics.transactionErrorRate.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullTransactionErrorRateDataPoints.length).to.be.greaterThan(0); + }); + + it('returns empty when empty service names is passed', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { start, end, serviceNames: JSON.stringify([]) }, + }) + ); + expect(response.status).to.be(400); + expect(response.body.message).to.equal('serviceNames cannot be empty'); + }); + + it('filters by environment', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { + start, + end, + serviceNames: JSON.stringify(serviceNames), + environment: 'production', + }, + }) + ); + expect(response.status).to.be(200); + expect(Object.keys(response.body.currentPeriod).length).to.be(1); + expect(response.body.currentPeriod['opbeans-java']).not.to.be.empty(); + }); + it('filters by kuery', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { + start, + end, + serviceNames: JSON.stringify(serviceNames), + kuery: 'transaction.type : "invalid_transaction_type"', + }, + }) + ); + expect(response.status).to.be(200); + expect(Object.keys(response.body.currentPeriod)).to.be.empty(); + }); + } + ); + + registry.when( + 'Services detailed statistics with time comparison', + { config: 'basic', archives: [archiveName] }, + () => { + let servicesDetailedStatistics: ServicesDetailedStatisticsReturn; + before(async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/detailed_statistics`, + query: { + start: moment(end).subtract(15, 'minutes').toISOString(), + end, + serviceNames: JSON.stringify(serviceNames), + offset: '15m', + }, + }) + ); + expect(response.status).to.be(200); + servicesDetailedStatistics = response.body; + }); + it('returns current period data', async () => { + expect(servicesDetailedStatistics.currentPeriod).not.to.be.empty(); + }); + it('returns previous period data', async () => { + expect(servicesDetailedStatistics.previousPeriod).not.to.be.empty(); + }); + it('returns current data for requested service names', () => { + serviceNames.forEach((serviceName) => { + expect(servicesDetailedStatistics.currentPeriod[serviceName]).not.to.be.empty(); + }); + }); + it('returns previous data for requested service names', () => { + serviceNames.forEach((serviceName) => { + expect(servicesDetailedStatistics.currentPeriod[serviceName]).not.to.be.empty(); + }); + }); + it('returns correct statistics', () => { + const currentPeriodStatistics = servicesDetailedStatistics.currentPeriod[serviceNames[0]]; + const previousPeriodStatistics = servicesDetailedStatistics.previousPeriod[serviceNames[0]]; + + expect(currentPeriodStatistics.latency.length).to.be.greaterThan(0); + expect(currentPeriodStatistics.throughput.length).to.be.greaterThan(0); + expect(currentPeriodStatistics.transactionErrorRate.length).to.be.greaterThan(0); + + // latency + const nonNullCurrentPeriodLantencyDataPoints = currentPeriodStatistics.latency.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullCurrentPeriodLantencyDataPoints.length).to.be.greaterThan(0); + + // throughput + const nonNullCurrentPeriodThroughputDataPoints = currentPeriodStatistics.throughput.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullCurrentPeriodThroughputDataPoints.length).to.be.greaterThan(0); + + // transaction erro rate + const nonNullCurrentPeriodTransactionErrorRateDataPoints = currentPeriodStatistics.transactionErrorRate.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullCurrentPeriodTransactionErrorRateDataPoints.length).to.be.greaterThan(0); + + expect(previousPeriodStatistics.latency.length).to.be.greaterThan(0); + expect(previousPeriodStatistics.throughput.length).to.be.greaterThan(0); + expect(previousPeriodStatistics.transactionErrorRate.length).to.be.greaterThan(0); + + // latency + const nonNullPreviousPeriodLantencyDataPoints = previousPeriodStatistics.latency.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullPreviousPeriodLantencyDataPoints.length).to.be.greaterThan(0); + + // throughput + const nonNullPreviousPeriodThroughputDataPoints = previousPeriodStatistics.throughput.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullPreviousPeriodThroughputDataPoints.length).to.be.greaterThan(0); + + // transaction erro rate + const nonNullPreviousPeriodTransactionErrorRateDataPoints = previousPeriodStatistics.transactionErrorRate.filter( + ({ y }) => isFiniteNumber(y) + ); + expect(nonNullPreviousPeriodTransactionErrorRateDataPoints.length).to.be.greaterThan(0); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/services/top_services.ts b/x-pack/test/apm_api_integration/tests/services/top_services.ts index 3bc66214e8360..df0c7c927780a 100644 --- a/x-pack/test/apm_api_integration/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/tests/services/top_services.ts @@ -85,93 +85,44 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns the correct metrics averages', () => { expectSnapshot( - sortedItems.map((item) => - pick( - item, - 'transactionErrorRate.value', - 'avgResponseTime.value', - 'transactionsPerMinute.value' - ) - ) + sortedItems.map((item) => pick(item, 'transactionErrorRate', 'latency', 'throughput')) ).toMatchInline(` Array [ Object {}, Object { - "avgResponseTime": Object { - "value": 520294.126436782, - }, - "transactionErrorRate": Object { - "value": 0.0316091954022989, - }, - "transactionsPerMinute": Object { - "value": 11.6, - }, + "latency": 520294.126436782, + "throughput": 11.6, + "transactionErrorRate": 0.0316091954022989, }, Object { - "avgResponseTime": Object { - "value": 74805.1452830189, - }, - "transactionErrorRate": Object { - "value": 0.00566037735849057, - }, - "transactionsPerMinute": Object { - "value": 17.6666666666667, - }, + "latency": 74805.1452830189, + "throughput": 17.6666666666667, + "transactionErrorRate": 0.00566037735849057, }, Object { - "avgResponseTime": Object { - "value": 411589.785714286, - }, - "transactionErrorRate": Object { - "value": 0.0848214285714286, - }, - "transactionsPerMinute": Object { - "value": 7.46666666666667, - }, + "latency": 411589.785714286, + "throughput": 7.46666666666667, + "transactionErrorRate": 0.0848214285714286, }, Object { - "avgResponseTime": Object { - "value": 53906.6603773585, - }, - "transactionErrorRate": Object { - "value": 0, - }, - "transactionsPerMinute": Object { - "value": 7.06666666666667, - }, + "latency": 53906.6603773585, + "throughput": 7.06666666666667, + "transactionErrorRate": 0, }, Object { - "avgResponseTime": Object { - "value": 420634.9, - }, - "transactionErrorRate": Object { - "value": 0.025, - }, - "transactionsPerMinute": Object { - "value": 5.33333333333333, - }, + "latency": 420634.9, + "throughput": 5.33333333333333, + "transactionErrorRate": 0.025, }, Object { - "avgResponseTime": Object { - "value": 40989.5802047782, - }, - "transactionErrorRate": Object { - "value": 0.00341296928327645, - }, - "transactionsPerMinute": Object { - "value": 9.76666666666667, - }, + "latency": 40989.5802047782, + "throughput": 9.76666666666667, + "transactionErrorRate": 0.00341296928327645, }, Object { - "avgResponseTime": Object { - "value": 1040880.77777778, - }, - "transactionErrorRate": Object { - "value": null, - }, - "transactionsPerMinute": Object { - "value": 2.4, - }, + "latency": 1040880.77777778, + "throughput": 2.4, + "transactionErrorRate": null, }, ] `); @@ -216,7 +167,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(rumServices.length).to.be.greaterThan(0); - expect(rumServices.every((item) => isEmpty(item.transactionErrorRate?.value))); + expect(rumServices.every((item) => isEmpty(item.transactionErrorRate))); }); it('non-RUM services all report transaction error rates', () => { @@ -226,10 +177,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect( nonRumServices.every((item) => { - return ( - typeof item.transactionErrorRate?.value === 'number' && - item.transactionErrorRate.timeseries.length > 0 - ); + return typeof item.transactionErrorRate === 'number'; }) ).to.be(true); });