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 77257f5af7c7e..f5dc0ca162b01 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,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
-import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
+import { SparkPlot } from '../../../shared/charts/spark_plot';
export function ServiceListMetric({
color,
@@ -15,11 +15,5 @@ export function ServiceListMetric({
series?: Array<{ x: number; y: number | null }>;
valueLabel: React.ReactNode;
}) {
- return (
-
- );
+ return ;
}
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx
index 1a86e7baac83f..6db5b1ae7bc7c 100644
--- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx
@@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- EuiFlexGroup,
- EuiFlexItem,
- EuiPage,
- EuiPanel,
- EuiTitle,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
+import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui';
import React from 'react';
import { useTrackPageview } from '../../../../../observability/public';
import { isRumAgentName } from '../../../../common/agent_name';
@@ -23,6 +16,7 @@ import { TransactionErrorRateChart } from '../../shared/charts/transaction_error
import { SearchBar } from '../../shared/search_bar';
import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table';
import { ServiceOverviewErrorsTable } from './service_overview_errors_table';
+import { ServiceOverviewInstancesTable } from './service_overview_instances_table';
import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart';
import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table';
@@ -101,36 +95,9 @@ export function ServiceOverview({
-
-
-
-
-
- {i18n.translate(
- 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle',
- {
- defaultMessage: 'Instances latency distribution',
- }
- )}
-
-
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.apm.serviceOverview.instancesTableTitle',
- {
- defaultMessage: 'Instances',
- }
- )}
-
-
-
-
-
+
+
+
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx
index 87ff702e0a960..b27941eee9beb 100644
--- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx
@@ -27,12 +27,12 @@ import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { TableLinkFlexItem } from '../table_link_flex_item';
import { AgentIcon } from '../../../shared/AgentIcon';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
-import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
+import { SparkPlot } from '../../../shared/charts/spark_plot';
import { px, unit } from '../../../../style/variables';
import { ImpactBar } from '../../../shared/ImpactBar';
import { ServiceOverviewLink } from '../../../shared/Links/apm/service_overview_link';
import { SpanIcon } from '../../../shared/span_icon';
-import { ServiceOverviewTableContainer } from '../service_overview_table';
+import { ServiceOverviewTableContainer } from '../service_overview_table_container';
interface Props {
serviceName: string;
@@ -88,7 +88,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { latency }) => {
return (
- {
return (
- {
return (
- {
return (
-
- {
- 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,
- },
- }}
- />
+
+ {
+ 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/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx
new file mode 100644
index 0000000000000..c9b4801883160
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx
@@ -0,0 +1,276 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexItem } from '@elastic/eui';
+import { EuiInMemoryTable } from '@elastic/eui';
+import { EuiTitle } from '@elastic/eui';
+import { EuiBasicTableColumn } from '@elastic/eui';
+import { EuiFlexGroup } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import { ValuesType } from 'utility-types';
+import { isJavaAgentName } from '../../../../../common/agent_name';
+import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n';
+import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes';
+import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
+import {
+ asDuration,
+ asPercent,
+ asTransactionRate,
+} from '../../../../../common/utils/formatters';
+import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
+import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
+import {
+ APIReturnType,
+ callApmApi,
+} from '../../../../services/rest/createCallApmApi';
+import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
+import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
+import { SparkPlot } from '../../../shared/charts/spark_plot';
+import { px, unit } from '../../../../style/variables';
+import { ServiceOverviewTableContainer } from '../service_overview_table_container';
+import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink';
+import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink';
+
+type ServiceInstanceItem = ValuesType<
+ APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>
+>;
+
+interface Props {
+ serviceName: string;
+}
+
+export function ServiceOverviewInstancesTable({ serviceName }: Props) {
+ const { agentName, transactionType } = useApmServiceContext();
+
+ const {
+ urlParams: { start, end },
+ uiFilters,
+ } = useUrlParams();
+
+ const columns: Array> = [
+ {
+ field: 'name',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnNodeName',
+ {
+ defaultMessage: 'Node name',
+ }
+ ),
+ render: (_, item) => {
+ const { serviceNodeName } = item;
+ const isMissingServiceNodeName =
+ serviceNodeName === SERVICE_NODE_NAME_MISSING;
+ const text = isMissingServiceNodeName
+ ? UNIDENTIFIED_SERVICE_NODES_LABEL
+ : serviceNodeName;
+
+ const link = isJavaAgentName(agentName) ? (
+
+ {text}
+
+ ) : (
+ ({
+ ...query,
+ kuery: isMissingServiceNodeName
+ ? `NOT (service.node.name:*)`
+ : `service.node.name:"${item.serviceNodeName}"`,
+ })}
+ >
+ {text}
+
+ );
+
+ return ;
+ },
+ sortable: true,
+ },
+ {
+ field: 'latencyValue',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnLatency',
+ {
+ defaultMessage: 'Latency',
+ }
+ ),
+ width: px(unit * 10),
+ render: (_, { latency }) => {
+ return (
+
+ );
+ },
+ sortable: true,
+ },
+ {
+ field: 'throughputValue',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnThroughput',
+ {
+ defaultMessage: 'Traffic',
+ }
+ ),
+ width: px(unit * 10),
+ render: (_, { throughput }) => {
+ return (
+
+ );
+ },
+ sortable: true,
+ },
+ {
+ field: 'errorRateValue',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnErrorRate',
+ {
+ defaultMessage: 'Error rate',
+ }
+ ),
+ width: px(unit * 8),
+ render: (_, { errorRate }) => {
+ return (
+
+ );
+ },
+ sortable: true,
+ },
+ {
+ field: 'cpuUsageValue',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnCpuUsage',
+ {
+ defaultMessage: 'CPU usage (avg.)',
+ }
+ ),
+ width: px(unit * 8),
+ render: (_, { cpuUsage }) => {
+ return (
+
+ );
+ },
+ sortable: true,
+ },
+ {
+ field: 'memoryUsageValue',
+ name: i18n.translate(
+ 'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage',
+ {
+ defaultMessage: 'Memory usage (avg.)',
+ }
+ ),
+ width: px(unit * 8),
+ render: (_, { memoryUsage }) => {
+ return (
+
+ );
+ },
+ sortable: true,
+ },
+ ];
+
+ const { data = [], status } = useFetcher(() => {
+ if (!start || !end || !transactionType) {
+ return;
+ }
+
+ return callApmApi({
+ endpoint:
+ 'GET /api/apm/services/{serviceName}/service_overview_instances',
+ params: {
+ path: {
+ serviceName,
+ },
+ query: {
+ start,
+ end,
+ transactionType,
+ uiFilters: JSON.stringify(uiFilters),
+ numBuckets: 20,
+ },
+ },
+ });
+ }, [start, end, serviceName, transactionType, uiFilters]);
+
+ // need top-level sortable fields for the managed table
+ const items = data.map((item) => ({
+ ...item,
+ latencyValue: item.latency?.value ?? 0,
+ throughputValue: item.throughput?.value ?? 0,
+ errorRateValue: item.errorRate?.value ?? 0,
+ cpuUsageValue: item.cpuUsage?.value ?? 0,
+ memoryUsageValue: item.memoryUsage?.value ?? 0,
+ }));
+
+ const isLoading =
+ status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING;
+
+ return (
+
+
+
+
+ {i18n.translate('xpack.apm.serviceOverview.instancesTableTitle', {
+ defaultMessage: 'All instances',
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx
similarity index 72%
rename from x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx
rename to x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx
index 99753adfcd36d..e5113cebd3dcb 100644
--- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx
@@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiBasicTable, EuiBasicTableProps } from '@elastic/eui';
-import React from 'react';
import styled from 'styled-components';
/**
@@ -43,14 +41,3 @@ export const ServiceOverviewTableContainer = styled.div<{
isEmptyAndLoading ? 'hidden' : 'visible'};
}
`;
-
-export function ServiceOverviewTable(props: EuiBasicTableProps) {
- const { items, loading } = props;
- const isEmptyAndLoading = !!(items.length === 0 && loading);
-
- return (
-
-
-
- );
-}
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
index 886c95cde7248..e50af6f53c728 100644
--- 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
@@ -15,6 +15,7 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import { EuiToolTip } from '@elastic/eui';
import { ValuesType } from 'utility-types';
+import { EuiBasicTable } from '@elastic/eui';
import { useLatencyAggregationType } from '../../../../hooks/use_latency_Aggregation_type';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import {
@@ -33,9 +34,9 @@ import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDeta
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 { SparkPlot } from '../../../shared/charts/spark_plot';
import { ImpactBar } from '../../../shared/ImpactBar';
-import { ServiceOverviewTable } from '../service_overview_table';
+import { ServiceOverviewTableContainer } from '../service_overview_table_container';
type ServiceTransactionGroupItem = ValuesType<
APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups']
@@ -208,7 +209,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { latency }) => {
return (
- {
return (
- {
return (
-
- {
- 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,
- },
- }}
- />
+
+ {
+ 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/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx
index 111dd5d00a978..7acc2542a65f3 100644
--- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx
+++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx
@@ -18,6 +18,7 @@ import { APMQueryParams, fromQuery, toQuery } from '../url_helpers';
interface Props extends EuiLinkAnchorProps {
path?: string;
query?: APMQueryParams;
+ mergeQuery?: (query: APMQueryParams) => APMQueryParams;
children?: React.ReactNode;
}
@@ -74,11 +75,14 @@ export function getAPMHref({
});
}
-export function APMLink({ path = '', query, ...rest }: Props) {
+export function APMLink({ path = '', query, mergeQuery, ...rest }: Props) {
const { core } = useApmPluginContext();
const { search } = useLocation();
const { basePath } = core.http;
- const href = getAPMHref({ basePath, path, search, query });
+
+ const mergedQuery = mergeQuery ? mergeQuery(query ?? {}) : query;
+
+ const href = getAPMHref({ basePath, path, search, query: mergedQuery });
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 ab1e725a08dff..0917839ad631b 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx
@@ -3,6 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+
+import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import React from 'react';
+import { EuiIcon } from '@elastic/eui';
import {
AreaSeries,
Chart,
@@ -10,62 +14,81 @@ import {
ScaleType,
Settings,
} from '@elastic/charts';
-import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
-import React from 'react';
import { merge } from 'lodash';
import { useChartTheme } from '../../../../../../observability/public';
-import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
-import { px } from '../../../../style/variables';
+import { px, unit } from '../../../../style/variables';
+import { useTheme } from '../../../../hooks/use_theme';
+
+type Color =
+ | 'euiColorVis0'
+ | 'euiColorVis1'
+ | 'euiColorVis2'
+ | 'euiColorVis3'
+ | 'euiColorVis4'
+ | 'euiColorVis5'
+ | 'euiColorVis6'
+ | 'euiColorVis7'
+ | 'euiColorVis8'
+ | 'euiColorVis9';
-interface Props {
- color: string;
+export function SparkPlot({
+ color,
+ series,
+ valueLabel,
+ compact,
+}: {
+ color: Color;
series?: Array<{ x: number; y: number | null }> | null;
- width: string;
-}
+ valueLabel: React.ReactNode;
+ compact?: boolean;
+}) {
+ const theme = useTheme();
+ const defaultChartTheme = useChartTheme();
-export function SparkPlot(props: Props) {
- const { series, color, width } = props;
- const chartTheme = useChartTheme();
+ const sparkplotChartTheme = merge({}, defaultChartTheme, {
+ lineSeriesStyle: {
+ point: { opacity: 0 },
+ },
+ areaSeriesStyle: {
+ point: { opacity: 0 },
+ },
+ });
- if (!series || series.every((point) => point.y === null)) {
- return (
-
-
-
-
-
-
- {NOT_AVAILABLE_LABEL}
-
-
-
- );
- }
+ const colorValue = theme.eui[color];
return (
-
-
-
-
+
+
+ {!series || series.every((point) => point.y === null) ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ {valueLabel}
+
+
);
}
diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx
deleted file mode 100644
index 7ca89c5a27504..0000000000000
--- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
-import React from 'react';
-import { px, unit } from '../../../../../style/variables';
-import { useTheme } from '../../../../../hooks/use_theme';
-import { SparkPlot } from '../';
-
-type Color =
- | 'euiColorVis0'
- | 'euiColorVis1'
- | 'euiColorVis2'
- | 'euiColorVis3'
- | 'euiColorVis4'
- | 'euiColorVis5'
- | 'euiColorVis6'
- | 'euiColorVis7'
- | 'euiColorVis8'
- | 'euiColorVis9';
-
-export function SparkPlotWithValueLabel({
- color,
- series,
- valueLabel,
- compact,
-}: {
- color: Color;
- series?: Array<{ x: number; y: number | null }> | null;
- valueLabel: React.ReactNode;
- compact?: boolean;
-}) {
- const theme = useTheme();
-
- const colorValue = theme.eui[color];
-
- return (
-
-
-
-
-
- {valueLabel}
-
-
- );
-}
diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts
new file mode 100644
index 0000000000000..7e9e04f3eaae4
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts
@@ -0,0 +1,150 @@
+/*
+ * 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 { AggregationOptionsByType } from '../../../../../../typings/elasticsearch';
+import { rangeFilter } from '../../../../common/utils/range_filter';
+import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes';
+import {
+ METRIC_CGROUP_MEMORY_USAGE_BYTES,
+ METRIC_PROCESS_CPU_PERCENT,
+ METRIC_SYSTEM_FREE_MEMORY,
+ METRIC_SYSTEM_TOTAL_MEMORY,
+ SERVICE_NAME,
+ SERVICE_NODE_NAME,
+} from '../../../../common/elasticsearch_fieldnames';
+import { ProcessorEvent } from '../../../../common/processor_event';
+import { ServiceInstanceParams } from '.';
+import { getBucketSize } from '../../helpers/get_bucket_size';
+import {
+ percentCgroupMemoryUsedScript,
+ percentSystemMemoryUsedScript,
+} from '../../metrics/by_agent/shared/memory';
+
+export async function getServiceInstanceSystemMetricStats({
+ setup,
+ serviceName,
+ size,
+ numBuckets,
+}: ServiceInstanceParams) {
+ const { apmEventClient, start, end, esFilter } = setup;
+
+ const { intervalString } = getBucketSize({ start, end, numBuckets });
+
+ const systemMemoryFilter = {
+ bool: {
+ filter: [
+ { exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
+ { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
+ ],
+ },
+ };
+
+ const cgroupMemoryFilter = {
+ exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES },
+ };
+
+ const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } };
+
+ function withTimeseries(agg: T) {
+ return {
+ avg: { avg: agg },
+ timeseries: {
+ date_histogram: {
+ field: '@timestamp',
+ fixed_interval: intervalString,
+ min_doc_count: 0,
+ extended_bounds: {
+ min: start,
+ max: end,
+ },
+ },
+ aggs: {
+ avg: { avg: agg },
+ },
+ },
+ };
+ }
+
+ const subAggs = {
+ memory_usage_cgroup: {
+ filter: cgroupMemoryFilter,
+ aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }),
+ },
+ memory_usage_system: {
+ filter: systemMemoryFilter,
+ aggs: withTimeseries({ script: percentSystemMemoryUsedScript }),
+ },
+ cpu_usage: {
+ filter: cpuUsageFilter,
+ aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }),
+ },
+ };
+
+ const response = await apmEventClient.search({
+ apm: {
+ events: [ProcessorEvent.metric],
+ },
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { range: rangeFilter(start, end) },
+ { term: { [SERVICE_NAME]: serviceName } },
+ ...esFilter,
+ ],
+ should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter],
+ minimum_should_match: 1,
+ },
+ },
+ aggs: {
+ [SERVICE_NODE_NAME]: {
+ terms: {
+ field: SERVICE_NODE_NAME,
+ missing: SERVICE_NODE_NAME_MISSING,
+ size,
+ },
+ aggs: subAggs,
+ },
+ },
+ },
+ });
+
+ return (
+ response.aggregations?.[SERVICE_NODE_NAME].buckets.map(
+ (serviceNodeBucket) => {
+ const hasCGroupData =
+ serviceNodeBucket.memory_usage_cgroup.avg.value !== null;
+
+ const memoryMetricsKey = hasCGroupData
+ ? 'memory_usage_cgroup'
+ : 'memory_usage_system';
+
+ return {
+ serviceNodeName: String(serviceNodeBucket.key),
+ cpuUsage: {
+ value: serviceNodeBucket.cpu_usage.avg.value,
+ timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map(
+ (dateBucket) => ({
+ x: dateBucket.key,
+ y: dateBucket.avg.value,
+ })
+ ),
+ },
+ memoryUsage: {
+ value: serviceNodeBucket[memoryMetricsKey].avg.value,
+ timeseries: serviceNodeBucket[
+ memoryMetricsKey
+ ].timeseries.buckets.map((dateBucket) => ({
+ x: dateBucket.key,
+ y: dateBucket.avg.value,
+ })),
+ },
+ };
+ }
+ ) ?? []
+ );
+}
diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts
new file mode 100644
index 0000000000000..4e3256f0fcf87
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts
@@ -0,0 +1,153 @@
+/*
+ * 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 { EventOutcome } from '../../../../common/event_outcome';
+import { rangeFilter } from '../../../../common/utils/range_filter';
+import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes';
+import {
+ EVENT_OUTCOME,
+ SERVICE_NAME,
+ SERVICE_NODE_NAME,
+ TRANSACTION_TYPE,
+} from '../../../../common/elasticsearch_fieldnames';
+import { ServiceInstanceParams } from '.';
+import { getBucketSize } from '../../helpers/get_bucket_size';
+import {
+ getProcessorEventForAggregatedTransactions,
+ getTransactionDurationFieldForAggregatedTransactions,
+} from '../../helpers/aggregated_transactions';
+
+export async function getServiceInstanceTransactionStats({
+ setup,
+ transactionType,
+ serviceName,
+ size,
+ searchAggregatedTransactions,
+ numBuckets,
+}: ServiceInstanceParams) {
+ const { apmEventClient, start, end, esFilter } = setup;
+
+ const { intervalString } = getBucketSize({ start, end, numBuckets });
+
+ const field = getTransactionDurationFieldForAggregatedTransactions(
+ searchAggregatedTransactions
+ );
+
+ const subAggs = {
+ count: {
+ value_count: {
+ field,
+ },
+ },
+ avg_transaction_duration: {
+ avg: {
+ field,
+ },
+ },
+ failures: {
+ filter: {
+ term: {
+ [EVENT_OUTCOME]: EventOutcome.failure,
+ },
+ },
+ aggs: {
+ count: {
+ value_count: {
+ field,
+ },
+ },
+ },
+ },
+ };
+
+ const response = await apmEventClient.search({
+ apm: {
+ events: [
+ getProcessorEventForAggregatedTransactions(
+ searchAggregatedTransactions
+ ),
+ ],
+ },
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { range: rangeFilter(start, end) },
+ { term: { [SERVICE_NAME]: serviceName } },
+ { term: { [TRANSACTION_TYPE]: transactionType } },
+ ...esFilter,
+ ],
+ },
+ },
+ aggs: {
+ [SERVICE_NODE_NAME]: {
+ terms: {
+ field: SERVICE_NODE_NAME,
+ missing: SERVICE_NODE_NAME_MISSING,
+ size,
+ },
+ aggs: {
+ ...subAggs,
+ timeseries: {
+ date_histogram: {
+ field: '@timestamp',
+ fixed_interval: intervalString,
+ min_doc_count: 0,
+ extended_bounds: {
+ min: start,
+ max: end,
+ },
+ },
+ aggs: {
+ ...subAggs,
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return (
+ response.aggregations?.[SERVICE_NODE_NAME].buckets.map(
+ (serviceNodeBucket) => {
+ const {
+ count,
+ avg_transaction_duration: avgTransactionDuration,
+ key,
+ failures,
+ timeseries,
+ } = serviceNodeBucket;
+
+ return {
+ serviceNodeName: String(key),
+ errorRate: {
+ value: failures.count.value / count.value,
+ timeseries: timeseries.buckets.map((dateBucket) => ({
+ x: dateBucket.key,
+ y: dateBucket.failures.count.value / dateBucket.count.value,
+ })),
+ },
+ throughput: {
+ value: count.value,
+ timeseries: timeseries.buckets.map((dateBucket) => ({
+ x: dateBucket.key,
+ y: dateBucket.count.value,
+ })),
+ },
+ latency: {
+ value: avgTransactionDuration.value,
+ timeseries: timeseries.buckets.map((dateBucket) => ({
+ x: dateBucket.key,
+ y: dateBucket.avg_transaction_duration.value,
+ })),
+ },
+ };
+ }
+ ) ?? []
+ );
+}
diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts
new file mode 100644
index 0000000000000..d627b968344f1
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 { joinByKey } from '../../../../common/utils/join_by_key';
+import { Setup, SetupTimeRange } from '../../helpers/setup_request';
+import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats';
+import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats';
+
+export interface ServiceInstanceParams {
+ setup: Setup & SetupTimeRange;
+ serviceName: string;
+ transactionType: string;
+ searchAggregatedTransactions: boolean;
+ size: number;
+ numBuckets: number;
+}
+
+export async function getServiceInstances(
+ params: Omit
+) {
+ const paramsForSubQueries = {
+ ...params,
+ size: 50,
+ };
+
+ const [transactionStats, systemMetricStats] = await Promise.all([
+ getServiceInstanceTransactionStats(paramsForSubQueries),
+ getServiceInstanceSystemMetricStats(paramsForSubQueries),
+ ]);
+
+ const stats = joinByKey(
+ [...transactionStats, ...systemMetricStats],
+ 'serviceNodeName'
+ );
+
+ return stats;
+}
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 d34e67083b037..09938ac7563d4 100644
--- a/x-pack/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts
@@ -24,6 +24,7 @@ import {
serviceErrorGroupsRoute,
serviceThroughputRoute,
serviceDependenciesRoute,
+ serviceInstancesRoute,
} from './services';
import {
agentConfigurationRoute,
@@ -124,6 +125,7 @@ const createApmApi = () => {
.add(serviceErrorGroupsRoute)
.add(serviceThroughputRoute)
.add(serviceDependenciesRoute)
+ .add(serviceInstancesRoute)
// 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 40ad7fdd05248..bba6afc332242 100644
--- a/x-pack/plugins/apm/server/routes/services.ts
+++ b/x-pack/plugins/apm/server/routes/services.ts
@@ -21,6 +21,7 @@ import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'
import { getServiceDependencies } from '../lib/services/get_service_dependencies';
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
import { getThroughput } from '../lib/services/get_throughput';
+import { getServiceInstances } from '../lib/services/get_service_instances';
export const servicesRoute = createRoute({
endpoint: 'GET /api/apm/services',
@@ -277,6 +278,38 @@ export const serviceThroughputRoute = createRoute({
},
});
+export const serviceInstancesRoute = createRoute({
+ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances',
+ params: t.type({
+ path: t.type({
+ serviceName: t.string,
+ }),
+ query: t.intersection([
+ t.type({ transactionType: t.string, numBuckets: toNumberRt }),
+ uiFiltersRt,
+ rangeRt,
+ ]),
+ }),
+ options: { tags: ['access:apm'] },
+ handler: async ({ context, request }) => {
+ const setup = await setupRequest(context, request);
+ const { serviceName } = context.params.path;
+ const { transactionType, numBuckets } = context.params.query;
+
+ const searchAggregatedTransactions = await getSearchAggregatedTransactions(
+ setup
+ );
+
+ return getServiceInstances({
+ serviceName,
+ setup,
+ transactionType,
+ searchAggregatedTransactions,
+ numBuckets,
+ });
+ },
+});
+
export const serviceDependenciesRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/dependencies',
params: t.type({
@@ -284,8 +317,11 @@ export const serviceDependenciesRoute = createRoute({
serviceName: t.string,
}),
query: t.intersection([
+ t.type({
+ environment: t.string,
+ numBuckets: toNumberRt,
+ }),
rangeRt,
- t.type({ environment: t.string, numBuckets: toNumberRt }),
]),
}),
options: {
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 3e625688e2459..902f48da92b1f 100644
--- a/x-pack/test/apm_api_integration/basic/tests/index.ts
+++ b/x-pack/test/apm_api_integration/basic/tests/index.ts
@@ -23,10 +23,10 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont
loadTestFile(require.resolve('./services/transaction_types'));
});
- // TODO: we should not have a service overview.
describe('Service overview', function () {
loadTestFile(require.resolve('./service_overview/error_groups'));
loadTestFile(require.resolve('./service_overview/dependencies'));
+ loadTestFile(require.resolve('./service_overview/instances'));
});
describe('Settings', function () {
diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts
new file mode 100644
index 0000000000000..084555387a690
--- /dev/null
+++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts
@@ -0,0 +1,214 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import expect from '@kbn/expect';
+import url from 'url';
+import { pick, sortBy } from 'lodash';
+import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number';
+import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi';
+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];
+
+ interface Response {
+ status: number;
+ body: APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>;
+ }
+
+ describe('Service overview instances', () => {
+ describe('when data is not loaded', () => {
+ it('handles the empty state', async () => {
+ const response: Response = await supertest.get(
+ url.format({
+ pathname: `/api/apm/services/opbeans-java/service_overview_instances`,
+ query: {
+ start,
+ end,
+ numBuckets: 20,
+ transactionType: 'request',
+ uiFilters: '{}',
+ },
+ })
+ );
+
+ expect(response.status).to.be(200);
+ expect(response.body).to.eql([]);
+ });
+ });
+
+ describe('when data is loaded', () => {
+ before(() => esArchiver.load(archiveName));
+ after(() => esArchiver.unload(archiveName));
+
+ describe('fetching java data', () => {
+ let response: Response;
+
+ beforeEach(async () => {
+ response = await supertest.get(
+ url.format({
+ pathname: `/api/apm/services/opbeans-java/service_overview_instances`,
+ query: {
+ start,
+ end,
+ numBuckets: 20,
+ transactionType: 'request',
+ uiFilters: '{}',
+ },
+ })
+ );
+ });
+
+ it('returns a service node item', () => {
+ expect(response.body.length).to.be.greaterThan(0);
+ });
+
+ it('returns statistics for each service node', () => {
+ const item = response.body[0];
+
+ expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true);
+ expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true);
+ expect(isFiniteNumber(item.errorRate?.value)).to.be(true);
+ expect(isFiniteNumber(item.throughput?.value)).to.be(true);
+ expect(isFiniteNumber(item.latency?.value)).to.be(true);
+
+ expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ });
+
+ it('returns the right data', () => {
+ const items = sortBy(response.body, 'serviceNodeName');
+
+ const serviceNodeNames = items.map((item) => item.serviceNodeName);
+
+ expectSnapshot(items.length).toMatchInline(`1`);
+
+ expectSnapshot(serviceNodeNames).toMatchInline(`
+ Array [
+ "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c",
+ ]
+ `);
+
+ const item = items[0];
+
+ const values = pick(item, [
+ 'cpuUsage.value',
+ 'memoryUsage.value',
+ 'errorRate.value',
+ 'throughput.value',
+ 'latency.value',
+ ]);
+
+ expectSnapshot(values).toMatchInline(`
+ Object {
+ "cpuUsage": Object {
+ "value": 0.0120166666666667,
+ },
+ "errorRate": Object {
+ "value": 0.16,
+ },
+ "latency": Object {
+ "value": 237339.813333333,
+ },
+ "memoryUsage": Object {
+ "value": 0.941324615478516,
+ },
+ "throughput": Object {
+ "value": 75,
+ },
+ }
+ `);
+ });
+ });
+
+ describe('fetching non-java data', () => {
+ let response: Response;
+
+ beforeEach(async () => {
+ response = await supertest.get(
+ url.format({
+ pathname: `/api/apm/services/opbeans-ruby/service_overview_instances`,
+ query: {
+ start,
+ end,
+ numBuckets: 20,
+ transactionType: 'request',
+ uiFilters: '{}',
+ },
+ })
+ );
+ });
+
+ it('returns statistics for each service node', () => {
+ const item = response.body[0];
+
+ expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true);
+ expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true);
+ expect(isFiniteNumber(item.errorRate?.value)).to.be(true);
+ expect(isFiniteNumber(item.throughput?.value)).to.be(true);
+ expect(isFiniteNumber(item.latency?.value)).to.be(true);
+
+ expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
+ });
+
+ it('returns the right data', () => {
+ const items = sortBy(response.body, 'serviceNodeName');
+
+ const serviceNodeNames = items.map((item) => item.serviceNodeName);
+
+ expectSnapshot(items.length).toMatchInline(`1`);
+
+ expectSnapshot(serviceNodeNames).toMatchInline(`
+ Array [
+ "_service_node_name_missing_",
+ ]
+ `);
+
+ const item = items[0];
+
+ const values = pick(
+ item,
+ 'cpuUsage.value',
+ 'errorRate.value',
+ 'throughput.value',
+ 'latency.value'
+ );
+
+ expectSnapshot(values).toMatchInline(`
+ Object {
+ "cpuUsage": Object {
+ "value": 0.00111666666666667,
+ },
+ "errorRate": Object {
+ "value": 0.0373134328358209,
+ },
+ "latency": Object {
+ "value": 70518.9328358209,
+ },
+ "throughput": Object {
+ "value": 134,
+ },
+ }
+ `);
+
+ expectSnapshot(values);
+ });
+ });
+ });
+ });
+}