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