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);
});